131 lines
4.8 KiB
Python
131 lines
4.8 KiB
Python
"""用真实的 Nginx 验证网关配置:反向代理、SPA 回退、SSE 不缓冲、限流、Cookie 灰度。
|
|
|
|
运行:python -m pytest -q test_gateway.py(需要本机安装 nginx;没有安装时自动跳过)
|
|
测试会启动两个 Python 后端(稳定版 / 灰度版)和一个非 root 的 Nginx,全部使用临时端口和临时目录。
|
|
"""
|
|
import http.client
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
NGINX = shutil.which("nginx") or ("/usr/sbin/nginx" if Path("/usr/sbin/nginx").exists() else None)
|
|
pytestmark = pytest.mark.skipif(NGINX is None, reason="没有安装 nginx")
|
|
HERE = Path(__file__).resolve().parent
|
|
|
|
|
|
def free_port() -> int:
|
|
with socket.socket() as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
def backend(version: str) -> ThreadingHTTPServer:
|
|
class H(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
def log_message(self, *a): # 安静
|
|
pass
|
|
|
|
def do_GET(self):
|
|
if self.path.startswith("/ai/stream"):
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.send_header("Connection", "close")
|
|
self.end_headers()
|
|
for i in range(3):
|
|
self.wfile.write(f"data: token{i}\n\n".encode())
|
|
self.wfile.flush()
|
|
time.sleep(0.3)
|
|
self.close_connection = True
|
|
return
|
|
body = f'{{"version":"{version}","xff":"{self.headers.get("X-Forwarded-For", "")}"}}'.encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
srv = ThreadingHTTPServer(("127.0.0.1", 0), H)
|
|
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
return srv
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def gateway(tmp_path_factory):
|
|
prefix = tmp_path_factory.mktemp("nginx")
|
|
(prefix / "html").mkdir()
|
|
(prefix / "html" / "index.html").write_text("<div id=app>SPA</div>")
|
|
stable, canary = backend("stable"), backend("canary")
|
|
port = free_port()
|
|
conf = (HERE / "nginx.conf.template").read_text()
|
|
for k, v in {"PREFIX": str(prefix), "STABLE_PORT": stable.server_address[1],
|
|
"CANARY_PORT": canary.server_address[1], "LISTEN_PORT": port}.items():
|
|
conf = conf.replace("{{" + k + "}}", str(v))
|
|
(prefix / "nginx.conf").write_text(conf)
|
|
check = subprocess.run([NGINX, "-t", "-p", str(prefix), "-c", str(prefix / "nginx.conf")],
|
|
capture_output=True, text=True)
|
|
assert check.returncode == 0, check.stderr
|
|
proc = subprocess.Popen([NGINX, "-p", str(prefix), "-c", str(prefix / "nginx.conf"), "-g", "daemon off;"],
|
|
stderr=subprocess.PIPE)
|
|
for _ in range(50):
|
|
try:
|
|
socket.create_connection(("127.0.0.1", port), timeout=0.1).close()
|
|
break
|
|
except OSError:
|
|
time.sleep(0.1)
|
|
yield port
|
|
proc.terminate()
|
|
proc.wait(5)
|
|
stable.shutdown()
|
|
canary.shutdown()
|
|
|
|
|
|
def get(port: int, path: str, headers: dict | None = None) -> tuple[int, str]:
|
|
c = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
|
|
c.request("GET", path, headers=headers or {})
|
|
r = c.getresponse()
|
|
body = r.read().decode()
|
|
c.close()
|
|
return r.status, body
|
|
|
|
|
|
def test_spa_fallback(gateway):
|
|
assert get(gateway, "/orders/42") == (200, "<div id=app>SPA</div>")
|
|
|
|
|
|
def test_proxy_and_canary(gateway):
|
|
status, body = get(gateway, "/api/hello")
|
|
assert status == 200 and '"version":"stable"' in body and '"xff":"127.0.0.1"' in body
|
|
time.sleep(1.2) # 等限流桶恢复
|
|
status, body = get(gateway, "/api/hello", {"Cookie": "canary=1"})
|
|
assert '"version":"canary"' in body
|
|
|
|
|
|
def test_sse_not_buffered(gateway):
|
|
c = http.client.HTTPConnection("127.0.0.1", gateway, timeout=5)
|
|
start = time.monotonic()
|
|
c.request("GET", "/ai/stream")
|
|
r = c.getresponse()
|
|
first = r.readline() # 读到第一条事件就返回
|
|
first_at = time.monotonic() - start
|
|
rest = r.read()
|
|
total = time.monotonic() - start
|
|
c.close()
|
|
assert first.startswith(b"data: token0")
|
|
assert b"token2" in rest
|
|
# 不缓冲时,第一条事件远早于整个响应结束(后端共耗时约 0.9 秒)
|
|
assert first_at < 0.5 < total, (first_at, total)
|
|
|
|
|
|
def test_rate_limit(gateway):
|
|
time.sleep(1.5)
|
|
codes = [get(gateway, "/api/hello")[0] for _ in range(20)]
|
|
assert codes.count(200) >= 5 and 429 in codes, codes
|