Files
DECNET/tests/live/test_https_live.py
anti 195580c74d test: fix templates paths, CLI gating, and stress-suite harness
- 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
2026-04-19 23:50:53 -04:00

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