Skip to content

Commit bedbecf

Browse files
committed
Tests are now CTR. Fold in pytest_suite
git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/branches/2.4.x@1935173 13f79535-47bb-0310-9956-ffa450edef68
1 parent 3471eab commit bedbecf

1,118 files changed

Lines changed: 25934 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGES

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
-*- coding: utf-8 -*-
22
Changes with Apache 2.4.69
33

4+
*) pytest_suite: Port of the old PERL test framework to Python
5+
and pytest. Now included in the source tree under ./test
6+
[Jim Jagielski]
7+
48
Changes with Apache 2.4.68
59

610
*) SECURITY: CVE-2026-49975: mod_http2 denial of service

test/pytest_suite/README.md

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
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.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Pytest-based test framework for Apache httpd (migration from Apache::Test).
2+
3+
Phase 1: server lifecycle, config generation, C-module compilation, HTTP client.
4+
Phase 2: test-facing API (t_cmp, need_* markers) used by the translated tests.
5+
"""
6+
7+
from .client import TestClient
8+
from .cmodules import clean_modules, compile_all
9+
from .config import TestConfig
10+
from .probe import HttpdInfo, probe
11+
from .server import HttpdServer
12+
from .testapi import (
13+
need_cgi,
14+
need_lwp,
15+
need_min_apache_version,
16+
need_module,
17+
need_php,
18+
need_ssl,
19+
t_cmp,
20+
)
21+
22+
__all__ = [
23+
"HttpdInfo",
24+
"HttpdServer",
25+
"TestClient",
26+
"TestConfig",
27+
"clean_modules",
28+
"compile_all",
29+
"need_cgi",
30+
"need_lwp",
31+
"need_min_apache_version",
32+
"need_module",
33+
"need_php",
34+
"need_ssl",
35+
"probe",
36+
"t_cmp",
37+
]

0 commit comments

Comments
 (0)