Files
FTL/test/api/test_openapi.py
T
010011110 1df62ec7a1 Import v5 domainlist_by_group after all domain lists
`process_received_tar_gz()` imports the Pi-hole v5 Teleporter files in
the order of `teleporter_v5_files[]`, and `domainlist_by_group.json`
came before `whitelist.exact.json` and `whitelist.regex.json`. The
`tr_domainlist_add` trigger adds every row inserted into `domainlist` to
the Default group (0), so the allowlist entries imported after the group
assignments kept that extra row on top of their archived groups. An
allow entry that v5 scoped to one group therefore applied to every
client in the Default group after the import, i.e. to all clients not
assigned elsewhere. Denylist entries were not affected because the
`domainlist_by_group` import replaced their trigger rows.

Move `domainlist_by_group.json` to the end of the list so the archived
mapping is applied after all four domain lists, like `adlist_by_group`
and `client_by_group` already are after their primary tables. The
`files` array in the reply now lists `domainlist_by_group.json` last.

Add a test that imports a small v5 archive and checks the groups of all
four domain list types. `test_teleporter_import` runs right after it and
restores `gravity.db` and `pihole.toml` from the exported ZIP archive.
The v5 import migrates the config and the ZIP import then has a changed
config to write back, which adds three `pihole.toml` writes to the count
in `test_final.bats`.

The same problem was reported for the v5 web Teleporter in
pi-hole/web#3020.

Signed-off-by: 010011110 <duckenheim@posteo.de>
2026-10-02 22:57:09 +02:00

247 lines
11 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_v5_domainlist_groups(self, ftl):
"""A v5 archive keeps the group assignments of all four domain lists.
The tr_domainlist_add trigger puts every imported domain into the
Default group (0); the archived domainlist_by_group has to replace
that for allow and deny entries alike. This import overwrites the
group and domainlist tables and pihole.toml, test_teleporter_import
below restores both from the ZIP exported earlier.
"""
import io
import json
import sqlite3
import tarfile
import time
import requests
now = int(time.time())
def entry(id, domain, type):
return {"id": id, "domain": domain, "enabled": 1,
"date_added": now, "date_modified": now,
"comment": "v5 import test", "type": type}
files = {
"group.json": [
{"id": 0, "enabled": 1, "name": "Default", "date_added": now,
"date_modified": now, "description": "The default group"},
{"id": 1, "enabled": 1, "name": "iot", "date_added": now,
"date_modified": now, "description": None}],
"whitelist.exact.json": [entry(9001, "allowiot.example", 0)],
"whitelist.regex.json": [entry(9002, "allowre\\.example$", 2)],
"blacklist.exact.json": [entry(9003, "denyiot.example", 1)],
"blacklist.regex.json": [entry(9004, "denyre\\.example$", 3)],
"domainlist_by_group.json": [
{"group_id": 1, "domainlist_id": id}
for id in (9001, 9002, 9003, 9004)],
# The config migration warns if there is no legacy file to read
"pihole-FTL.conf": b"# v5 import test\n",
}
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for name, content in files.items():
data = content if isinstance(content, bytes) else json.dumps(content).encode()
info = tarfile.TarInfo(name)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
with open("/var/log/pihole/FTL.log", "r") as f:
f.seek(0, 2)
log_pos = f.tell()
response = ftl.POST("/api/teleporter", None, AuthenticationMethods.HEADER,
{"file": ("pi-hole-teleporter.tar.gz", buf.getvalue(),
"application/gzip")})
assert response is not None and "domainlist_by_group.json" in response.get("files", []), \
f"v5 Teleporter import failed: {response} {ftl.errors}"
# The import is written before the reply, the restart follows it
with sqlite3.connect("file:/etc/pihole/gravity.db?mode=ro", uri=True) as db:
rows = db.execute("SELECT d.domain, group_concat(g.group_id) "
"FROM domainlist d JOIN domainlist_by_group g "
"ON g.domainlist_id = d.id WHERE d.id >= 9001 "
"GROUP BY d.id ORDER BY d.id").fetchall()
expected = [("allowiot.example", "1"), ("allowre\\.example$", "1"),
("denyiot.example", "1"), ("denyre\\.example$", "1")]
assert rows == expected, f"domainlist groups after v5 import: {rows}"
# Wait for the restarted FTL to serve the API again
for _ in range(60):
time.sleep(0.5)
with open("/var/log/pihole/FTL.log", "r") as f:
f.seek(log_pos)
if "FTL started on" not in f.read():
continue
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 v5 teleporter import")
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. Teleporter imports are the only API calls that
restart FTL - 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")