blob: 8d3fd2111c6f6875f621f318d88338d11b349c81 [file]
"""Pytest fixtures wiring the httpd test framework together.
Session scope: probe httpd -> generate config -> compile C modules -> start
server, then stop it at the end. Function scope: an HTTP client and a vhost
resolver. Mirrors the orchestration Apache::TestRun performs.
Requirement markers (need_module, need_min_apache_version, need_cgi, need_php,
need_ssl, need_lwp) declared via apache_pytest.testapi are evaluated at
collection time against a one-time probe of the target httpd, skipping tests
whose requirements aren't met -- the analog of Apache::Test's plan-time skips.
CLI options:
--httpd path to the httpd binary (default: derived from --apxs SBINDIR)
--apxs path to apxs (used to locate httpd + the inherited install conf,
and to build the C modules)
--defines space-separated extra -D defines (e.g. 'LDAP')
"""
from __future__ import annotations
import subprocess
from pathlib import Path
import pytest
from apache_pytest import HttpdServer, TestClient, TestConfig, compile_all, probe
from apache_pytest.cmodules import clean_modules
from apache_pytest.probe import HttpdInfo
# The suite is self-contained: all assets it needs (t/conf templates, t/htdocs
# docroot, c-modules sources, auth files) live under this directory (python/).
# SUITE_ROOT is therefore this file's directory -- nothing outside it is read.
SUITE_ROOT = Path(__file__).resolve().parent
# Backwards-compatible alias (older code referenced REPO_ROOT).
REPO_ROOT = SUITE_ROOT
def pytest_addoption(parser: pytest.Parser) -> None:
group = parser.getgroup("httpd")
group.addoption("--httpd", action="store", default=None, help="path to httpd binary")
group.addoption("--apxs", action="store", default=None, help="path to apxs")
group.addoption(
"--defines",
action="store",
default="",
help="space-separated extra -D defines (e.g. 'LDAP')",
)
group.addoption(
"--php-fpm",
action="store",
default=None,
help="path to a php-fpm binary; enables the t/php tests via mod_proxy_fcgi "
"(any version/location -- the path is the only PHP-specific input)",
)
group.addoption(
"--php-fpm-port",
action="store",
type=int,
default=8999,
help="TCP port for the managed php-fpm pool (default 8999)",
)
group.addoption(
"--clean-modules",
action="store_true",
default=False,
help="remove all compiled C-module artifacts before building (emulate make clean)",
)
group.addoption(
"--conf",
action="store",
default=None,
help="path to the installed httpd.conf (for LoadModule discovery "
"when --apxs is not available, e.g. on Windows)",
)
group.addoption(
"--prefix",
action="store",
default=None,
help="server install prefix for resolving relative module paths "
"(default: derived from --conf path)",
)
def pytest_configure(config: pytest.Config) -> None:
for marker in (
"need_module(*names): skip unless all named httpd modules are available",
"need_min_apache_version(ver): skip unless server >= ver",
"need_cgi(): skip unless a CGI module is available",
"need_php(): skip unless a PHP SAPI module is available",
"need_ssl(): skip unless mod_ssl is available",
"need_lwp(): always satisfied (httpx client always present)",
):
config.addinivalue_line("markers", marker)
def _apxs_query(apxs: Path, var: str) -> str:
proc = subprocess.run( # noqa: S603 - trusted path
["perl", str(apxs), "-q", var], capture_output=True, text=True, check=True
)
return proc.stdout.strip()
class _NoServerError(Exception):
"""Raised when neither --httpd nor --apxs was provided."""
def _resolve_paths(
config: pytest.Config,
) -> tuple[Path, Path | None, Path | None, Path | None, list[str]]:
"""Resolve (httpd, apxs, inherited_conf, install_prefix, defines) from options.
Raises :class:`_NoServerError` if neither --httpd nor --apxs is given. (A
plain exception, not pytest.fail, so the collection-time probe can catch it
without turning into an INTERNALERROR; the fixture converts it to a clean
skip/fail at setup time.)
"""
apxs_opt = config.getoption("--apxs")
httpd_opt = config.getoption("--httpd")
defines = [d for d in config.getoption("--defines").split() if d]
apxs = Path(apxs_opt) if apxs_opt else None
inherited_conf: Path | None = None
install_prefix: Path | None = None
if apxs is not None:
sbindir = Path(_apxs_query(apxs, "SBINDIR"))
sysconfdir = Path(_apxs_query(apxs, "SYSCONFDIR"))
install_prefix = Path(_apxs_query(apxs, "PREFIX"))
inherited_conf = sysconfdir / "httpd.conf"
if httpd_opt is None:
httpd_opt = str(sbindir / "httpd")
conf_opt = config.getoption("--conf")
prefix_opt = config.getoption("--prefix")
if conf_opt is not None and inherited_conf is None:
inherited_conf = Path(conf_opt)
if prefix_opt is not None:
install_prefix = Path(prefix_opt)
elif inherited_conf is not None and install_prefix is None:
install_prefix = inherited_conf.parent.parent
if httpd_opt is None:
raise _NoServerError("must pass --httpd or --apxs")
return Path(httpd_opt), apxs, inherited_conf, install_prefix, defines
# --------------------------------------------------------------------------- #
# Collection-time requirement gating (need_* markers)
# --------------------------------------------------------------------------- #
_probe_cache: HttpdInfo | None = None
def _probed_info(config: pytest.Config) -> HttpdInfo | None:
"""Probe the target httpd once for collection-time requirement checks.
Returns None if no httpd was specified (so requirement markers can't be
evaluated -- tests are left to fail/skip naturally at fixture setup).
"""
global _probe_cache # noqa: PLW0603 - simple memo across the collection hook
if _probe_cache is not None:
return _probe_cache
try:
httpd, _apxs, inherited_conf, install_prefix, _defines = _resolve_paths(config)
except Exception:
return None
info = probe(httpd, inherited_conf, install_prefix)
# The bundled C test modules (authany, echo_post, input_body_filter, ...) are
# compiled and LoadModule'd into the generated test config by the session
# fixture, so need_module("authany") etc. should be satisfied at collection
# time too. Augment the probed set with the C modules that WILL be built
# (honoring the same HTTPD_TEST_REQUIRE_APACHE gating discover() applies).
# Without apxs the modules can't be compiled, so don't promise them.
if _apxs is not None:
from apache_pytest.cmodules import discover
cmods, _skipped = discover(REPO_ROOT / "c-modules", info)
for mod in cmods:
info.modules.add(f"mod_{mod.name}.c")
_probe_cache = info
return _probe_cache
def _unmet_requirement(
item: pytest.Item, info: HttpdInfo, *, php_fpm: bool = False
) -> str | None:
"""Return a skip reason if any need_* marker on ``item`` is unmet, else None.
``php_fpm`` indicates a php-fpm binary was provided (and mod_proxy_fcgi is
available), which satisfies need_php even without an in-process PHP SAPI.
"""
for mark in item.iter_markers(name="need_module"):
for name in mark.args:
if not info.has_module(name):
return f"module {name} not available"
for mark in item.iter_markers(name="need_min_apache_version"):
ver = mark.args[0]
parts = tuple(int(x) for x in str(ver).split("."))
parts += (0,) * (3 - len(parts))
if info.version < parts:
return f"httpd {info.version_str} < required {ver}"
if any(item.iter_markers(name="need_cgi")) and not (
info.has_module("mod_cgi") or info.has_module("mod_cgid")
):
return "no CGI module (mod_cgi/mod_cgid) available"
if any(item.iter_markers(name="need_php")):
php_mods = ("sapi_apache2", "mod_php4", "mod_php5", "mod_php7", "mod_php")
have_sapi = any(info.has_module(m) for m in php_mods)
have_fpm = php_fpm and info.has_module("mod_proxy_fcgi")
if not (have_sapi or have_fpm):
return "no PHP SAPI module or php-fpm available"
if any(item.iter_markers(name="need_ssl")) and not info.has_module("mod_ssl"):
return "mod_ssl not available"
# need_lwp is always satisfied (httpx is always present); no check.
return None
def pytest_collection_modifyitems(
config: pytest.Config, items: list[pytest.Item]
) -> None:
info = _probed_info(config)
if info is None:
return
php_fpm = bool(config.getoption("--php-fpm"))
for item in items:
reason = _unmet_requirement(item, info, php_fpm=php_fpm)
if reason:
item.add_marker(pytest.mark.skip(reason=reason))
# --------------------------------------------------------------------------- #
# Server lifecycle + client fixtures
# --------------------------------------------------------------------------- #
@pytest.fixture(scope="session")
def framework(request: pytest.FixtureRequest):
"""Probe, configure, build C modules, start httpd; yield (config, server)."""
try:
httpd, apxs, inherited_conf, install_prefix, defines = _resolve_paths(request.config)
except _NoServerError as exc:
pytest.fail(str(exc))
info = probe(httpd, inherited_conf, install_prefix)
# Optional PHP-FPM: if a php-fpm binary was given and mod_proxy_fcgi is
# available, route htdocs/php/*.php to a managed FPM daemon. The php-fpm
# path/port are the only PHP-specific inputs -- no version is assumed.
php_fpm_opt = request.config.getoption("--php-fpm")
fpm_port = int(request.config.getoption("--php-fpm-port"))
fpm_mgr = None
use_fpm = bool(php_fpm_opt) and info.has_module("mod_proxy_fcgi")
config = TestConfig(
info=info,
top_dir=REPO_ROOT,
defines=defines,
apxs=apxs,
fpm_port=fpm_port if use_fpm else None,
)
cmodule_loads: list[tuple[str, Path]] = []
if apxs is not None:
cmodules_dir = REPO_ROOT / "c-modules"
if request.config.getoption("--clean-modules"):
clean_modules(cmodules_dir)
cmodule_loads, _skipped = compile_all(
cmodules_dir, apxs, info, defines=["APACHE2", "APACHE2_4", *defines]
)
config.generate(cmodule_loads=cmodule_loads)
if use_fpm:
from apache_pytest.fpm import PhpFpm
fpm_mgr = PhpFpm(
Path(php_fpm_opt),
run_dir=Path(config.vars["t_logs"]) / "php-fpm",
port=fpm_port,
)
fpm_mgr.start()
# Record the error_log size right before this session's httpd starts.
# error_log is opened in append mode and t_logs/ is not cleaned between
# invocations, so it can carry entries from earlier, unrelated test runs
# (possibly hours/days old, with different pids). Tests that need "since
# this server session started" (as opposed to "since this individual
# test started") must scope their log reads to this offset, not to
# position 0 -- see test_proxy_beacon.py.
error_log = Path(config.vars["t_logs"]) / "error_log"
config.vars["session_log_start"] = str(
error_log.stat().st_size if error_log.exists() else 0
)
server = HttpdServer(config)
server.start()
try:
yield config, server
finally:
server.stop()
if fpm_mgr is not None:
fpm_mgr.stop()
@pytest.fixture(scope="session")
def config(framework) -> TestConfig:
return framework[0]
@pytest.fixture(scope="session")
def server(framework) -> HttpdServer:
return framework[1]
@pytest.fixture
def http(config: TestConfig):
"""Function-scoped HTTP client bound to the running server."""
client = TestClient(config)
try:
yield client
finally:
client.close()