|
| 1 | +# Apache httpd test suite — Python / pytest |
| 2 | + |
| 3 | +> **Provenance / how this fits in `test/`.** This `pytest_suite/` is a |
| 4 | +> self-contained snapshot ported from the `httpd-tests` repository (the Python |
| 5 | +> port of the classic Perl `Apache::Test` suite). It **coexists alongside** |
| 6 | +> `test/pyhttpd/` and `test/modules/` — it does *not* use pyhttpd's framework |
| 7 | +> or its root `conftest.py`. It has its own `conftest.py` and `pyproject.toml`, |
| 8 | +> so pytest treats this directory as its own rootdir; run it from here (or via |
| 9 | +> `test/run-all-tests.sh`), never by pointing pytest at `test/` as a whole. |
| 10 | +> The two suites are complementary: this one covers the classic core/modules/ |
| 11 | +> security/SSL/PHP tests; pyhttpd covers HTTP/2, mod_md (ACME), HTTP/1 and proxy. |
| 12 | +> To re-sync from upstream, re-copy the source tree (source files only — exclude |
| 13 | +> `.venv/`, caches, and generated artifacts; see `.gitignore`). |
| 14 | +
|
| 15 | +A self-contained [pytest](https://pytest.org) port of the Apache httpd |
| 16 | +integration test suite (historically the Perl `Apache::Test` framework). It |
| 17 | +generates an httpd configuration, compiles the bundled C test modules, starts a |
| 18 | +private httpd instance (and optionally PHP-FPM), exercises it over HTTP/HTTPS, |
| 19 | +and shuts everything down — all driven by ordinary pytest test functions. |
| 20 | + |
| 21 | +Everything the suite needs at runtime lives under this directory; it |
| 22 | +does not read anything from the rest of the repository. The only external |
| 23 | +inputs are the **Apache httpd build under test** (located via `apxs`) and, |
| 24 | +optionally, a **`php-fpm`** binary for the PHP tests. |
| 25 | + |
| 26 | + |
| 27 | +## Quick start |
| 28 | + |
| 29 | +```sh |
| 30 | +# 1. Create the virtualenv (pytest + httpx). Needs `uv` (https://docs.astral.sh/uv/), |
| 31 | +# or substitute a plain venv -- see "Environment" below. |
| 32 | +uv sync |
| 33 | + |
| 34 | +# 2. Run the whole suite against your httpd build. |
| 35 | +./runtests.sh --apxs /path/to/your/httpd/bin/apxs |
| 36 | + |
| 37 | +# ...or let it auto-detect apxs / httpd / php-fpm from $PATH or env vars: |
| 38 | +APXS=$HOME/root/httpd/bin/apxs PHP_FPM=/usr/sbin/php-fpm8.3 ./runtests.sh |
| 39 | +``` |
| 40 | + |
| 41 | +`runtests.sh` is the self-evident entry point: it locates the venv's `pytest`, |
| 42 | +fills in `--apxs` / `--php-fpm` from environment variables or `$PATH`, clears a |
| 43 | +stale CGI socket, and forwards any extra arguments to pytest. |
| 44 | + |
| 45 | +```sh |
| 46 | +./runtests.sh tests/t/modules # run one category |
| 47 | +./runtests.sh tests/t/modules/test_alias.py # run one file |
| 48 | +./runtests.sh -k rewrite -v # any pytest args pass through |
| 49 | +./runtests.sh --php-fpm /opt/local/sbin/php-fpm83 tests/t/php # PHP tests |
| 50 | +``` |
| 51 | + |
| 52 | + |
| 53 | +## What you need |
| 54 | + |
| 55 | +| Requirement | How it's provided | Notes | |
| 56 | +|---|---|---| |
| 57 | +| Python ≥ 3.11 + pytest + httpx | `uv sync` (uses `pyproject.toml` / `uv.lock`) | The only Python deps. | |
| 58 | +| A built Apache httpd | `--apxs` / `--httpd` (or `APXS` / `HTTPD` env) | The server under test. Built with shared modules so the suite can load them. | |
| 59 | +| `php-fpm` (optional) | `--php-fpm` (or `PHP_FPM` env) | Any PHP 7/8 version. Without it, the PHP tests skip. | |
| 60 | +| `openssl` CLI | auto (on `$PATH`) | Used to generate the test SSL CA/certs. | |
| 61 | + |
| 62 | +The suite probes the httpd build (`httpd -v/-V/-l` and its installed |
| 63 | +`httpd.conf`) to learn its version, MPM, and available modules, then **skips** |
| 64 | +any test whose required module/version isn't present. A passing run therefore |
| 65 | +reports a mix of `passed` and `skipped` — skips are expected and fine. |
| 66 | + |
| 67 | + |
| 68 | +## Command-line options |
| 69 | + |
| 70 | +These are added on top of pytest's own options: |
| 71 | + |
| 72 | +| Option | Meaning | |
| 73 | +|---|---| |
| 74 | +| `--apxs PATH` | Path to `apxs`. Used to locate `httpd`, the inherited `httpd.conf`, the install prefix, and to compile the C test modules. | |
| 75 | +| `--httpd PATH` | Path to the `httpd` binary (defaults to `apxs -q SBINDIR`/httpd). | |
| 76 | +| `--php-fpm PATH` | Path to a `php-fpm` binary. Enables the PHP tests by routing `*.php` to a managed FPM pool via `mod_proxy_fcgi`. Version/location-agnostic. | |
| 77 | +| `--php-fpm-port N` | TCP port for the managed php-fpm pool (default `8999`). | |
| 78 | +| `--defines "A B"` | Extra `-D` defines passed to httpd (e.g. `LDAP`). | |
| 79 | + |
| 80 | + |
| 81 | +## Layout |
| 82 | + |
| 83 | +``` |
| 84 | +python/ |
| 85 | +├── runtests.sh # entry point (this is how you run the suite) |
| 86 | +├── pyproject.toml # deps + pytest config |
| 87 | +├── conftest.py # fixtures: server lifecycle, the `http` client, need_* gating |
| 88 | +├── apache_pytest/ # the framework (port of Apache::Test internals) |
| 89 | +│ ├── probe.py # inspect the httpd build (version, MPM, modules) |
| 90 | +│ ├── config.py # generate httpd.conf from t/conf/*.conf.in |
| 91 | +│ ├── cmodules.py # compile c-modules/ via apxs |
| 92 | +│ ├── sslca.py # generate the SSL test CA + certs |
| 93 | +│ ├── fpm.py # manage a php-fpm daemon |
| 94 | +│ ├── server.py # start/stop httpd |
| 95 | +│ ├── client.py # the `http` fixture (HTTP/HTTPS + raw sockets) |
| 96 | +│ ├── rawsocket.py # raw-socket helper for protocol tests |
| 97 | +│ └── testapi.py # t_cmp() + need_* markers used by tests |
| 98 | +├── tests/ |
| 99 | +│ ├── test_framework_smoke.py # framework self-tests |
| 100 | +│ ├── test_config_parity.py # dev-only: diff vs the Perl reference (skips if absent) |
| 101 | +│ └── t/ # the translated suite, mirroring the historical t/ layout |
| 102 | +│ ├── apache/ # core HTTP/protocol tests |
| 103 | +│ ├── modules/ # per-module tests (proxy, rewrite, headers, ...) |
| 104 | +│ ├── ssl/ # TLS / client-cert tests |
| 105 | +│ ├── security/ # CVE regression tests |
| 106 | +│ ├── php/ # PHP tests (need --php-fpm) |
| 107 | +│ ├── http11/ filter/ protocol/ apr/ ab/ |
| 108 | +│ └── ... |
| 109 | +├── t/ # runtime assets (self-contained copy) |
| 110 | +│ ├── conf/ # *.conf.in templates + ssl/ |
| 111 | +│ ├── htdocs/ # document root served by the test server |
| 112 | +│ └── php-fpm/ # php-fpm pool assets |
| 113 | +└── c-modules/ # C test-module sources, compiled via apxs at runtime |
| 114 | +``` |
| 115 | + |
| 116 | +Generated files (`t/conf/*.conf`, `t/logs/`, `t/conf/ssl/ca/`) are produced at |
| 117 | +run time and are safe to delete between runs. |
| 118 | + |
| 119 | + |
| 120 | +## Adding a new test |
| 121 | + |
| 122 | +Tests are plain pytest functions. They receive the running server through the |
| 123 | +`http` fixture and assert on its responses. Put a test in the category |
| 124 | +directory that matches what it exercises, in a file named `test_<name>.py`, with |
| 125 | +functions named `test_*`. |
| 126 | + |
| 127 | +### 1. Minimal example |
| 128 | + |
| 129 | +`tests/t/modules/test_status.py`: |
| 130 | + |
| 131 | +```python |
| 132 | +import re |
| 133 | +from apache_pytest import need_module |
| 134 | + |
| 135 | +@need_module("status") # skip unless mod_status is loaded |
| 136 | +def test_server_status(http): |
| 137 | + servername = http.vars("servername") |
| 138 | + body = http.GET_BODY("/server-status") |
| 139 | + assert re.search(f"Apache Server Status for {servername}", body, re.I) |
| 140 | +``` |
| 141 | + |
| 142 | +That's the whole pattern: |
| 143 | + |
| 144 | +* **`http`** is the fixture giving you the running server. Request methods return |
| 145 | + an [`httpx.Response`](https://www.python-httpx.org/api/#response). |
| 146 | +* **`@need_module("x")`** (and friends, below) gate the test — if the |
| 147 | + requirement isn't met, the test is skipped at collection time. This is the |
| 148 | + Python equivalent of the Perl `plan tests => N, need_module 'x'`. |
| 149 | +* Use plain `assert`. Add a message for context: `assert cond, "what failed"`. |
| 150 | + |
| 151 | +### 2. The `http` fixture API |
| 152 | + |
| 153 | +Requests (bare paths are resolved against the running server; absolute URLs pass |
| 154 | +through): |
| 155 | + |
| 156 | +| Method | Returns | Notes | |
| 157 | +|---|---|---| |
| 158 | +| `http.GET(path, **kw)` | `Response` | also `HEAD`, `OPTIONS`, `PUT`, `POST` | |
| 159 | +| `http.POST(path, content=b"...", **kw)` | `Response` | `content=` is the request body | |
| 160 | +| `http.GET_BODY(path)` | `str` | response text | |
| 161 | +| `http.GET_RC(path)` | `int` | status code (returns `500` on a transport/TLS error, like LWP) | |
| 162 | +| `http.POST_BODY(path, content=...)` | `str` | | |
| 163 | + |
| 164 | +Useful keyword args (forwarded to httpx): `headers={...}`, `redirect_ok=True` |
| 165 | +(follow 3xx — off by default), `cert="client_ok"` (present a client certificate; |
| 166 | +see SSL below), `auth=httpx.BasicAuth(u, p)`. |
| 167 | + |
| 168 | +Server introspection / configuration: |
| 169 | + |
| 170 | +| Call | Purpose | |
| 171 | +|---|---| |
| 172 | +| `http.vars("servername")` | a value from the generated config's vars table (`servername`, `port`, `documentroot`, `t_logs`, ...) | |
| 173 | +| `http.have_module("rewrite")` | is a module loaded? (runtime check, in addition to the `@need_module` gate) | |
| 174 | +| `http.have_min_apache_version("2.4.50")` | runtime version gate | |
| 175 | +| `http.apxs("INCLUDEDIR")` | query the build's `apxs` | |
| 176 | +| `http.scheme("https")` | switch subsequent requests to HTTPS (the mod_ssl vhost) | |
| 177 | +| `http.module("proxy_http_reverse")` | target a specific configured virtual host by module name | |
| 178 | +| `http.vhost_url("mod_headers", "/x")` / `http.hostport("mod_ssl")` | build a URL / host:port for a named vhost | |
| 179 | + |
| 180 | +Comparison helper (a faithful port of Perl's `t_cmp`): `t_cmp(received, |
| 181 | +expected)` returns a bool. If `expected` is a compiled regex it does a |
| 182 | +`re.search`; otherwise it compares by string equality. Use it when you want the |
| 183 | +regex-or-equal behavior: |
| 184 | + |
| 185 | +```python |
| 186 | +from apache_pytest import t_cmp |
| 187 | +assert t_cmp(r.status_code, 200), "status" |
| 188 | +assert t_cmp(r.headers.get("Allow", ""), re.compile("OPTIONS")), "Allow header" |
| 189 | +``` |
| 190 | + |
| 191 | +### 3. Requirement markers (skip gating) |
| 192 | + |
| 193 | +Import from `apache_pytest` and apply as decorators. A test is skipped (at |
| 194 | +collection time) unless every marker is satisfied by the probed httpd build: |
| 195 | + |
| 196 | +```python |
| 197 | +from apache_pytest import ( |
| 198 | + need_module, need_min_apache_version, need_cgi, need_ssl, need_php, need_lwp, |
| 199 | +) |
| 200 | + |
| 201 | +@need_module("proxy", "setenvif") # all named modules must be loaded |
| 202 | +@need_min_apache_version("2.4.49") # server must be >= this version |
| 203 | +def test_something(http): |
| 204 | + ... |
| 205 | +``` |
| 206 | + |
| 207 | +| Marker | Satisfied when | |
| 208 | +|---|---| |
| 209 | +| `need_module("x", ...)` | every named module is loaded (bare name, `mod_x`, or `mod_x.c` all accepted; bundled C test modules count) | |
| 210 | +| `need_min_apache_version("2.4.x")` | server version ≥ given | |
| 211 | +| `need_cgi()` | `mod_cgi` or `mod_cgid` present | |
| 212 | +| `need_ssl()` | `mod_ssl` present | |
| 213 | +| `need_php()` | a PHP SAPI module **or** `--php-fpm` (with `mod_proxy_fcgi`) is available | |
| 214 | +| `need_lwp()` | always (kept for 1:1 readability with the Perl originals) | |
| 215 | + |
| 216 | +For *conditional* logic inside a test (not whole-test gating), use the runtime |
| 217 | +checks and `pytest.skip`: |
| 218 | + |
| 219 | +```python |
| 220 | +def test_versioned(http): |
| 221 | + if not http.have_min_apache_version("2.4.60"): |
| 222 | + pytest.skip("needs >= 2.4.60") |
| 223 | + ... |
| 224 | +``` |
| 225 | + |
| 226 | +### 4. Parametrizing (loops in the Perl original) |
| 227 | + |
| 228 | +Where a Perl test looped over cases, use `@pytest.mark.parametrize`: |
| 229 | + |
| 230 | +```python |
| 231 | +import pytest |
| 232 | +from apache_pytest import need_module, t_cmp |
| 233 | + |
| 234 | +CASES = [("/index.html", 200), ("/missing", 404)] |
| 235 | + |
| 236 | +@need_module("alias") |
| 237 | +@pytest.mark.parametrize(("path", "code"), CASES, ids=lambda c: str(c)) |
| 238 | +def test_alias(http, path, code): |
| 239 | + assert t_cmp(http.GET(path).status_code, code), path |
| 240 | +``` |
| 241 | + |
| 242 | +### 5. SSL / client certificates |
| 243 | + |
| 244 | +Switch the client to HTTPS and (optionally) present one of the generated test |
| 245 | +client certs (`client_ok`, `client_snakeoil`, `client_revoked`, `client_colon`): |
| 246 | + |
| 247 | +```python |
| 248 | +from apache_pytest import need_ssl |
| 249 | + |
| 250 | +@need_ssl() |
| 251 | +def test_client_cert(http): |
| 252 | + http.scheme("https") |
| 253 | + assert http.GET_RC("/require/asf/index.html", cert="client_ok") == 200 |
| 254 | + assert http.GET_RC("/require/asf/index.html", cert=None) != 200 |
| 255 | +``` |
| 256 | + |
| 257 | +The framework generates the CA/server/client certificates automatically and the |
| 258 | +client trusts the test CA. TLS 1.3 post-handshake auth works. |
| 259 | + |
| 260 | +### 6. Raw sockets (protocol-level tests) |
| 261 | + |
| 262 | +For tests that send hand-crafted (often malformed) requests and read the raw |
| 263 | +response — e.g. many CVE regressions: |
| 264 | + |
| 265 | +```python |
| 266 | +import re |
| 267 | +from apache_pytest import need_module, t_cmp |
| 268 | + |
| 269 | +@need_module("proxy") |
| 270 | +def test_bad_request(http): |
| 271 | + http.module("cve_2011_3368") # select the vhost |
| 272 | + sock = http.vhost_socket() # raw socket to that vhost's port |
| 273 | + try: |
| 274 | + sock.print( |
| 275 | + "GET @localhost/foo HTTP/1.1\r\n" |
| 276 | + f"Host: {http.hostport()}\r\n\r\n" |
| 277 | + ) |
| 278 | + line = sock.getline() or "" |
| 279 | + assert t_cmp(line, re.compile(r"^HTTP/1\.. 400")), "rejected" |
| 280 | + finally: |
| 281 | + sock.close() |
| 282 | +``` |
| 283 | + |
| 284 | +`VhostSocket` offers `.print(data)`, `.getline()`, `.read()`, `.connected`, |
| 285 | +`.socket_trace(True)`, and `.close()`; it's also a context manager. |
| 286 | + |
| 287 | +> **Tip for HTTP/1.1 raw requests:** after sending, half-close the write side |
| 288 | +> (`sock._sock.shutdown(socket.SHUT_WR)`) so a keep-alive connection doesn't |
| 289 | +> block on the read timeout. |
| 290 | +
|
| 291 | +### 7. Server-side config and document root |
| 292 | + |
| 293 | +If a test needs server configuration or files served from disk: |
| 294 | + |
| 295 | +* **Static files / scripts** go under `t/htdocs/` (the document root). A request |
| 296 | + for `/foo/bar.html` is served from `t/htdocs/foo/bar.html`. |
| 297 | +* **Per-module httpd config** lives in the `t/conf/*.conf.in` templates, which |
| 298 | + use `@TOKEN@` placeholders (`@SERVERROOT@`, `@DOCUMENTROOT@`, `@PORT@`, |
| 299 | + `@SSL_MODULE@`, ...) and `<VirtualHost module_name>` blocks that the framework |
| 300 | + rewrites to allocated ports. Add config to the relevant `.conf.in` (most |
| 301 | + general-purpose config is in `t/conf/extra.conf.in`); a request can target a |
| 302 | + named vhost with `http.module("name")` / `http.vhost_url("name")`. |
| 303 | +* **CGI / helper scripts** can be shipped as `*.PL` templates under `t/htdocs/`; |
| 304 | + they're generated into executable scripts at run time. |
| 305 | + |
| 306 | +### 8. Conventions |
| 307 | + |
| 308 | +* Mirror the historical layout: a test for `t/<category>/<name>.t` becomes |
| 309 | + `tests/t/<category>/test_<name>.py` (hyphens in the name → underscores, e.g. |
| 310 | + `CVE-2011-3368.t` → `test_CVE_2011_3368.py`). |
| 311 | +* Keep a short module docstring noting what's tested (and, for ports, the Perl |
| 312 | + original). If the docstring quotes Perl containing backslashes, make it a raw |
| 313 | + string (`r"""..."""`). |
| 314 | +* A new test must end **passing or skipping** — never erroring. If it can't run |
| 315 | + in some environment, gate it with a `need_*` marker or `pytest.skip` with a |
| 316 | + clear reason rather than letting it fail. |
| 317 | + |
| 318 | + |
| 319 | +## Notes & gotchas |
| 320 | + |
| 321 | +* **`uv run` vs the venv.** `runtests.sh` invokes `.venv/bin/pytest` directly so |
| 322 | + it works in environments where `uv run` is shimmed/unavailable. If you call |
| 323 | + pytest yourself, use `.venv/bin/pytest` (or activate the venv). |
| 324 | +* **Same-named test modules** across categories (e.g. `ssl/test_all.py` and |
| 325 | + `php/test_all.py`) coexist thanks to `--import-mode=importlib` + |
| 326 | + `pythonpath=["."]` in `pyproject.toml`. |
| 327 | +* **Stale CGI socket.** A killed run can leave `t/logs/cgisock*`, which breaks a |
| 328 | + fresh start; `runtests.sh` removes it automatically. |
| 329 | +* **No orphans.** The server is launched in its own process group and reaped on |
| 330 | + teardown (including after a failed start); php-fpm is stopped likewise. After |
| 331 | + a run, `pgrep -f 'bin/httpd'` and `pgrep -f php-fpm` should be empty. |
| 332 | +* **`test_config_parity.py`** is a development-only check that diffs the |
| 333 | + generated config against a freshly Perl-generated reference. It skips cleanly |
| 334 | + when the Perl `Apache::Test` framework isn't present, so it's inert in a |
| 335 | + released, standalone copy of this directory. |
0 commit comments