- tests/**: update templates/ → decnet/templates/ paths after module move - tests/mysql_spinup.sh: use root:root and asyncmy driver - tests/test_auto_spawn.py: patch decnet.cli.utils._pid_dir (package split) - tests/test_cli.py: set DECNET_MODE=master in api-command tests - tests/stress/conftest.py: run locust out-of-process via its CLI + CSV stats shim to avoid urllib3 RecursionError from late gevent monkey-patch; raise uvicorn startup timeout to 60s, accept 401 from auth-gated health, strip inherited DECNET_* env, surface stderr on 0-request runs - tests/stress/test_stress.py: loosen baseline thresholds to match hw
191 lines
6.0 KiB
Python
191 lines
6.0 KiB
Python
import os
|
|
import queue
|
|
import socket
|
|
import ssl
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import requests
|
|
from urllib3.exceptions import InsecureRequestWarning
|
|
|
|
from tests.live.conftest import assert_rfc5424
|
|
|
|
_REPO_ROOT = Path(__file__).parent.parent.parent
|
|
_TEMPLATES = _REPO_ROOT / "decnet" / "templates"
|
|
_VENV_PYTHON = _REPO_ROOT / ".venv" / "bin" / "python"
|
|
_PYTHON = str(_VENV_PYTHON) if _VENV_PYTHON.exists() else sys.executable
|
|
|
|
|
|
def _free_port() -> int:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
def _wait_for_tls_port(port: int, timeout: float = 10.0) -> bool:
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
ctx = ssl.create_default_context()
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
with socket.create_connection(("127.0.0.1", port), timeout=0.5) as sock:
|
|
with ctx.wrap_socket(sock, server_hostname="127.0.0.1"):
|
|
return True
|
|
except (OSError, ssl.SSLError):
|
|
time.sleep(0.1)
|
|
return False
|
|
|
|
|
|
def _drain(q: queue.Queue, timeout: float = 2.0) -> list[str]:
|
|
lines: list[str] = []
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
lines.append(q.get(timeout=max(0.01, deadline - time.monotonic())))
|
|
except queue.Empty:
|
|
break
|
|
return lines
|
|
|
|
|
|
def _generate_self_signed_cert(cert_path: str, key_path: str) -> None:
|
|
subprocess.run(
|
|
[
|
|
"openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
|
"-keyout", key_path, "-out", cert_path,
|
|
"-days", "1", "-subj", "/CN=localhost",
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
|
|
|
|
class _HTTPSServiceProcess:
|
|
"""Manages an HTTPS service subprocess with TLS cert generation."""
|
|
|
|
def __init__(self, port: int, cert_path: str, key_path: str):
|
|
template_dir = _TEMPLATES / "https"
|
|
env = {
|
|
**os.environ,
|
|
"NODE_NAME": "test-node",
|
|
"PORT": str(port),
|
|
"PYTHONPATH": str(template_dir),
|
|
"LOG_TARGET": "",
|
|
"TLS_CERT": cert_path,
|
|
"TLS_KEY": key_path,
|
|
}
|
|
self._proc = subprocess.Popen(
|
|
[_PYTHON, str(template_dir / "server.py")],
|
|
cwd=str(template_dir),
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
env=env,
|
|
text=True,
|
|
)
|
|
self._q: queue.Queue = queue.Queue()
|
|
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
|
self._reader.start()
|
|
|
|
def _read_loop(self) -> None:
|
|
assert self._proc.stdout is not None
|
|
for line in self._proc.stdout:
|
|
self._q.put(line.rstrip("\n"))
|
|
|
|
def drain(self, timeout: float = 2.0) -> list[str]:
|
|
return _drain(self._q, timeout)
|
|
|
|
def stop(self) -> None:
|
|
self._proc.terminate()
|
|
try:
|
|
self._proc.wait(timeout=3)
|
|
except subprocess.TimeoutExpired:
|
|
self._proc.kill()
|
|
self._proc.wait()
|
|
|
|
|
|
@pytest.fixture
|
|
def https_service():
|
|
"""Start an HTTPS server with a temporary self-signed cert."""
|
|
started: list[_HTTPSServiceProcess] = []
|
|
tmp_dirs: list[tempfile.TemporaryDirectory] = []
|
|
|
|
def _start() -> tuple[int, callable]:
|
|
port = _free_port()
|
|
tmp = tempfile.TemporaryDirectory()
|
|
tmp_dirs.append(tmp)
|
|
cert_path = os.path.join(tmp.name, "cert.pem")
|
|
key_path = os.path.join(tmp.name, "key.pem")
|
|
_generate_self_signed_cert(cert_path, key_path)
|
|
|
|
svc = _HTTPSServiceProcess(port, cert_path, key_path)
|
|
started.append(svc)
|
|
if not _wait_for_tls_port(port):
|
|
svc.stop()
|
|
pytest.fail(f"HTTPS service did not bind to port {port} within 10s")
|
|
svc.drain(timeout=0.3)
|
|
return port, svc.drain
|
|
|
|
yield _start
|
|
|
|
for svc in started:
|
|
svc.stop()
|
|
for tmp in tmp_dirs:
|
|
tmp.cleanup()
|
|
|
|
|
|
@pytest.mark.live
|
|
class TestHTTPSLive:
|
|
def test_get_request_logged(self, https_service):
|
|
port, drain = https_service()
|
|
resp = requests.get(
|
|
f"https://127.0.0.1:{port}/admin", timeout=5, verify=False,
|
|
)
|
|
assert resp.status_code == 403
|
|
lines = drain()
|
|
assert_rfc5424(lines, service="https", event_type="request")
|
|
|
|
def test_tls_handshake(self, https_service):
|
|
port, drain = https_service()
|
|
ctx = ssl.create_default_context()
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
|
|
with ctx.wrap_socket(sock, server_hostname="127.0.0.1") as tls:
|
|
assert tls.version() is not None
|
|
|
|
def test_server_header_set(self, https_service):
|
|
port, drain = https_service()
|
|
resp = requests.get(
|
|
f"https://127.0.0.1:{port}/", timeout=5, verify=False,
|
|
)
|
|
assert "Server" in resp.headers
|
|
assert resp.headers["Server"] != ""
|
|
|
|
def test_post_body_logged(self, https_service):
|
|
port, drain = https_service()
|
|
requests.post(
|
|
f"https://127.0.0.1:{port}/login",
|
|
data={"username": "admin", "password": "secret"},
|
|
timeout=5,
|
|
verify=False,
|
|
)
|
|
lines = drain()
|
|
assert any("body=" in line for line in lines if "request" in line), (
|
|
"Expected 'body=' in request log line. Got:\n" + "\n".join(lines[:10])
|
|
)
|
|
|
|
def test_method_and_path_in_log(self, https_service):
|
|
port, drain = https_service()
|
|
requests.get(
|
|
f"https://127.0.0.1:{port}/secret/file.txt", timeout=5, verify=False,
|
|
)
|
|
lines = drain()
|
|
matched = assert_rfc5424(lines, service="https", event_type="request")
|
|
assert "GET" in matched or 'method="GET"' in matched
|
|
assert "/secret/file.txt" in matched or 'path="/secret/file.txt"' in matched
|