Files
FTL/test/api/test_openapi.py
T
Boris Rybalkin b12d2c51ff config: make the install/config directory a compile-time option (PIHOLE_INSTALL_DIR)
All of Pi-hole's configuration and state currently lives under a hardcoded
/etc/pihole. Packagers that install FTL into a different prefix (snaps, /opt
installs, read-only-root images, ...) have to patch the sources.

Introduce a single compile-time base directory, PIHOLE_INSTALL_DIR, defaulting
to /etc/pihole so behaviour is unchanged, and route the /etc/pihole paths
through it: the hardcoded config/state defines (pihole.toml, the legacy config,
dnsmasq.conf, hosts, custom.list, dhcp.leases, backups, cli_pw, versions, the
inotify watch dir), the embedded dnsmasq CONFFILE, and the /etc/pihole defaults
of the runtime-settable config options (files.database, files.tmp_db,
files.gravity, files.macvendor, webserver.tls.cert). Packagers can now relocate
the tree with

    cmake -DPIHOLE_INSTALL_DIR=/opt/pihole/etc ...

This is a build-time knob only and deliberately does not add a runtime override
(files.pid stays fixed per GHSA-6w8x-p785-6pm4). The /var/log/pihole log
defaults are left untouched as they are a different base directory.

Teleporter archives stay portable across installs, so the archive entry names
are pinned to their canonical etc/pihole/... values regardless of where the
files live on disk - only the on-disk source path is relocated. Export
previously derived every name from the absolute path minus its leading slash,
which would have made a relocated build write opt/pihole/etc/... while the
import side matched fixed names, silently skipping the affected files. That
applied to pihole.toml and dhcp.leases via PIHOLE_INSTALL_DIR, and to the
gravity and FTL databases via the runtime files.gravity/files.database values -
the latter reachable even without relocation, for anyone who moves gravity.db.
All four now go through ZIPNAME_TOML/ZIPNAME_DHCPLEASES/ZIPNAME_GRAVITY/
ZIPNAME_FTLDB, shared by export and import, and a test exports an archive and
asserts the names. Import targets are unchanged: gravity is still imported
table-wise into whatever files.gravity points at.

The embedded OpenAPI specs are run through configure_file() so the five
relocatable defaults they document (cert, database, tmp_db, gravity, macvendor)
and the two adlists.list mentions in lists.yaml follow the configured value
instead of documenting paths a relocated install does not use. The two help
strings naming the directory (webserver.api.cli_pw, debug.inotify) follow it as
well, as they are rendered into pihole.toml and served in the API docs.

CMake rejects a relative or trailing-slash PIHOLE_INSTALL_DIR, as the teleporter
strips the leading slash by offset, and one containing a percent sign, as the
value reaches printf format strings by concatenation in existing log messages
(e.g. main.c "Parsed config file "GLOBALTOMLPATH" successfully"). The check
carries a comment naming those call sites so it is not relaxed without
converting them first. The --gen-x509 and --read-x509 usage examples pass the
directory as an argument rather than concatenating it, matching
write_config_header().

Signed-off-by: Boris Rybalkin <ribalkin@gmail.com>
2026-08-30 17:28:06 +03:00

167 lines
6.7 KiB
Python

"""
Pi-hole FTL OpenAPI specification validation tests.
Verifies that FTL's API implementation matches the OpenAPI specs:
- Endpoint coverage (OpenAPI ↔ FTL cross-check)
- Response schema validation (types, formats, examples)
- Teleporter export/import round-trip
Ported from checkAPI.py. Reuses the existing libs/ utilities.
Usage:
pytest test/api/test_openapi.py -v
"""
import pytest
from libs.FTLAPI import FTLAPI, AuthenticationMethods
from libs.openAPI import openApi
from libs.responseVerifyer import ResponseVerifyer
# ---------------------------------------------------------------------------
# Helpers for parametrize — collect endpoint lists at import time is not
# possible (needs fixtures). Instead, tests iterate inside the body and
# use subtests-style assertions, or we use indirect fixtures.
# We use a hybrid: fixtures provide the data, tests iterate with clear
# error messages per endpoint.
# ---------------------------------------------------------------------------
class TestEndpointCoverage:
"""Cross-check that OpenAPI specs and FTL agree on available endpoints."""
def test_openapi_get_endpoints_exist_in_ftl(self, openapi, ftl):
"""Every GET endpoint in the OpenAPI specs is implemented in FTL."""
missing = []
for path in openapi.endpoints["get"]:
if path not in ftl.endpoints["get"]:
missing.append(path)
assert missing == [], \
"GET endpoints in OpenAPI specs but not in FTL:\n" + \
"\n".join(f" {p}" for p in missing)
def test_ftl_get_endpoints_exist_in_openapi(self, openapi, ftl):
"""Every GET endpoint in FTL is documented in the OpenAPI specs."""
# /api/docs is intentionally undocumented
skip = {"/api/docs"}
missing = []
for path in ftl.endpoints["get"]:
if path in skip:
continue
if path not in openapi.endpoints["get"]:
missing.append(path)
assert missing == [], \
"GET endpoints in FTL but not in OpenAPI specs:\n" + \
"\n".join(f" {p}" for p in missing)
def test_all_endpoints_cross_check(self, openapi, ftl):
"""Full bidirectional check across all HTTP methods."""
with ResponseVerifyer(ftl, openapi) as verifyer:
errors, checked = verifyer.verify_endpoints()
assert errors == [], \
f"Endpoint cross-check errors ({checked} checked):\n" + \
"\n".join(f" {e}" for e in errors)
class TestEndpointResponses:
"""Validate each GET endpoint's response against its OpenAPI schema."""
def test_get_endpoint_responses(self, openapi, ftl):
"""Each GET endpoint's response matches its OpenAPI spec.
Skips /api/action/* endpoints (would trigger unwanted actions).
Reports all failures with the endpoint path for easy identification.
"""
all_errors = {}
teleporter_archive = None
for path in openapi.endpoints["get"]:
if path.startswith("/api/action"):
continue
with ResponseVerifyer(ftl, openapi) as verifyer:
errors = verifyer.verify_endpoint(path)
if verifyer.teleporter_archive is not None:
teleporter_archive = verifyer.teleporter_archive
if len(errors) > 0:
all_errors[path] = (verifyer.auth_method, errors)
# Store teleporter archive for the teleporter tests
TestEndpointResponses._teleporter_archive = teleporter_archive
assert all_errors == {}, \
"Endpoint response validation errors:\n" + \
"\n".join(
f" GET {path} ({auth} auth):\n" +
"\n".join(f" - {e}" for e in errs)
for path, (auth, errs) in all_errors.items()
)
# Store across test instances
_teleporter_archive = None
class TestTeleporter:
"""Teleporter export/import round-trip via API."""
def test_teleporter_export_entry_names(self, ftl):
"""Exported archives must use the canonical etc/pihole/... entry names.
The names are independent of PIHOLE_INSTALL_DIR and of the runtime
files.gravity / files.database values, so that archives stay portable
between differently configured installations and the import side
(which matches fixed names) keeps accepting them.
"""
import io
import zipfile
archive = ftl.GET("/api/teleporter", [], "application/zip",
AuthenticationMethods.HEADER)
assert archive is not None, \
"Teleporter export failed:\n" + \
"\n".join(f" - {e}" for e in ftl.errors)
names = zipfile.ZipFile(io.BytesIO(archive)).namelist()
for expected in ("etc/pihole/pihole.toml", "etc/pihole/gravity.db",
"etc/pihole/pihole-FTL.db"):
assert expected in names, \
f"Teleporter archive does not contain {expected}:\n" + \
"\n".join(f" - {n}" for n in names)
leases = [n for n in names if n.endswith("dhcp.leases")]
assert leases in ([], ["etc/pihole/dhcp.leases"]), \
f"Teleporter archive stores DHCP leases under {leases}, " \
"expected etc/pihole/dhcp.leases"
def test_teleporter_import(self, openapi, ftl):
"""Re-import the teleporter ZIP archive exported during response tests.
Teleporter import triggers an internal FTL restart (gravity
database reload, exit code 22). We wait for FTL to come back
afterwards so subsequent tests (auth, rate limiting) have a
working API. Note: this is the only API call that causes an
FTL restart — password hashing (BALLOON-SHA256) and all other
config changes are fully synchronous and do not restart FTL.
"""
import time
import requests
archive = TestEndpointResponses._teleporter_archive
if archive is None:
pytest.skip("No teleporter archive captured during response tests")
with ResponseVerifyer(ftl, openapi) as verifyer:
errors = verifyer.verify_teleporter_zip(archive)
assert errors == [], \
"Teleporter import errors:\n" + \
"\n".join(f" - {e}" for e in errors)
# Wait for FTL to complete its internal restart after teleporter import
for _ in range(30):
time.sleep(0.5)
try:
r = requests.get("http://127.0.0.1/api/auth", timeout=2)
if r.status_code in (200, 401):
return
except requests.ConnectionError:
continue
pytest.fail("FTL did not come back after teleporter import")