Files
NetAlertX/test/backend/test_app_conf_string_escaping.py
2026-10-09 09:59:14 +11:00

153 lines
6.0 KiB
Python

"""
NetAlertX app.conf String Escaping Tests
Dispatches the real front/php/server/util.php savesettings path through the PHP
CLI against a temporary config directory, then checks that the generated
app.conf compiles without warnings and parses back to the typed values (with '
mapped to {s-quote}) for both scalar string and array string settings.
License: GNU GPLv3
"""
import json
import os
import shutil
import subprocess
import warnings
from pathlib import Path
import pytest
FRONT_DIR = Path(__file__).resolve().parents[2] / "front"
PHP_BIN = shutil.which("php") or shutil.which("php83")
pytestmark = pytest.mark.skipif(PHP_BIN is None, reason="PHP CLI (php or php83) not available")
# Stands in for the web request: reads {"front": ..., "settings": [...]} from stdin,
# satisfies security.php's request-only dependencies and lets util.php dispatch savesettings.
# util.php only reads function/settings from $_POST (not $_REQUEST) and requires a matching
# csrf_token - seed $_SESSION directly (same PHP process, so security.php's session_start()
# just resumes it) rather than $_REQUEST, to mirror a real authenticated POST.
PHP_RUNNER = (
'$in = json_decode(stream_get_contents(STDIN), true);'
'if (!function_exists("apache_request_headers")) { function apache_request_headers() { return []; } }'
'$_SERVER["DOCUMENT_ROOT"] = $in["front"];'
'$_SERVER["HTTP_HOST"] = "localhost";'
'$_SERVER["REQUEST_URI"] = "/php/server/util.php";'
'session_start();'
'$_SESSION["csrf_token"] = "test-harness-csrf-token";'
'$_POST = ["function" => "savesettings", "csrf_token" => "test-harness-csrf-token", '
'"settings" => json_encode($in["settings"])];'
'require $in["front"] . "/php/server/util.php";'
)
# Minimal app.conf that lets globals.php and security.php load without a password prompt.
SEED_APP_CONF = "TIMEZONE='UTC'\nSETPWD_enable_password=False\n"
CASES = {
"ordinary_text": "hello world",
"doc_regex": r"192\.0\.2\..*",
"consecutive_backslashes": r"a\\b",
"backslashes_only": "\\" * 3,
"trailing_backslash": "trail" + "\\",
"existing_s_quote": "x{s-quote}y",
"literal_single_quote": "it's",
"regex_with_quote": r"\d+\s*'",
"backslash_before_quote": r"a\'b",
}
def scalar_key(case_id):
"""Return the app.conf key used for the scalar string setting of a case."""
return f"S_{case_id.upper()}"
def array_key(case_id):
"""Return the app.conf key used for the array string setting of a case."""
return f"A_{case_id.upper()}"
@pytest.fixture(scope="module")
def app_conf(tmp_path_factory):
"""Run util.php saveSettings() once for all cases and return the generated app.conf source."""
root = tmp_path_factory.mktemp("app_conf")
config_dir = root / "config"
api_dir = root / "api"
session_dir = root / "session"
for folder in (config_dir, api_dir, session_dir):
folder.mkdir()
(config_dir / "app.conf").write_text(SEED_APP_CONF)
# UI_WAIT_FOR_SETTINGS=True keeps getReloadWaitRequired() from reading the (absent) API files.
settings = [["General", "UI_WAIT_FOR_SETTINGS", "boolean", True]]
for case_id, typed in CASES.items():
settings.append(["Test", scalar_key(case_id), "string", typed])
settings.append(["Test", array_key(case_id), "array", ["plain", typed]])
env = dict(os.environ, NETALERTX_CONFIG=str(config_dir), NETALERTX_API=str(api_dir))
result = subprocess.run(
[PHP_BIN, "-d", f"session.save_path={session_dir}", "-d", "display_errors=stderr", "-r", PHP_RUNNER],
input=json.dumps({"front": str(FRONT_DIR), "settings": settings}),
capture_output=True,
text=True,
env=env,
timeout=60,
check=True,
)
assert json.loads(result.stdout)["success"] is True, result.stdout + result.stderr
return (config_dir / "app.conf").read_text()
def setting_line(source, key):
"""Return the single app.conf line that assigns key."""
lines = [line for line in source.splitlines() if line.startswith(f"{key}=")]
assert len(lines) == 1, f"expected one {key}= line, got {lines}"
return lines[0]
def parse_app_conf(source):
"""Compile source with all warnings as errors and exec it like the backend app.conf readers."""
with warnings.catch_warnings():
warnings.simplefilter("error")
code = compile(source, "app.conf", "exec")
conf = {}
exec(code, {"__builtins__": {}}, conf)
return conf
def test_warning_check_rejects_unescaped_backslash():
"""The warnings-as-errors compile rejects the unescaped regex source that util.php emitted before escaping."""
with pytest.raises(SyntaxError):
parse_app_conf(r"X='192\.0\.2\..*'")
def test_generated_app_conf_compiles(app_conf):
"""The whole app.conf written by saveSettings() compiles without warnings."""
parse_app_conf(app_conf)
@pytest.mark.parametrize("case_id", CASES.keys())
def test_scalar_string_round_trip(app_conf, case_id):
"""A scalar string setting written by saveSettings() compiles cleanly and parses back to the typed value."""
key = scalar_key(case_id)
conf = parse_app_conf(setting_line(app_conf, key))
assert conf[key] == CASES[case_id].replace("'", "{s-quote}")
@pytest.mark.parametrize("case_id", CASES.keys())
def test_array_string_round_trip(app_conf, case_id):
"""An array string setting written by saveSettings() compiles cleanly and parses back to the typed values."""
key = array_key(case_id)
conf = parse_app_conf(setting_line(app_conf, key))
assert conf[key] == ["plain", CASES[case_id].replace("'", "{s-quote}")]
def test_doc_regex_exact_source(app_conf):
"""The documentation regex is emitted with doubled backslashes and parses back unchanged."""
scalar = setting_line(app_conf, scalar_key("doc_regex"))
array = setting_line(app_conf, array_key("doc_regex"))
assert scalar == r"S_DOC_REGEX='192\\.0\\.2\\..*'"
assert array == r"A_DOC_REGEX=['plain','192\\.0\\.2\\..*']"
assert parse_app_conf(scalar)["S_DOC_REGEX"] == r"192\.0\.2\..*"
assert parse_app_conf(array)["A_DOC_REGEX"] == ["plain", r"192\.0\.2\..*"]