Files
hair/tests/test_gateway_interface52.py
T
xsl 14ec4964a0 feat(gateway): 为52号接口增加客户端静态Token校验
请求头X-API-Token默认yonghe1999,可通过interface52_token覆盖;缺少或不匹配返回401/code1009,不进入派发队列。客户端Token不转发到worker,其他接口保持原样。

同步Token输入框、配置示例与调用文档。57项网关测试及临时网关连接真实worker的浏览器验证通过。
2026-11-04 11:35:02 +08:00

229 lines
10 KiB
Python

"""Gateway-only interface 52 integration: raw forwarding, token and image URLs."""
import asyncio
import base64
import importlib
import io
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import httpx
from PIL import Image
import pytest
from fastapi.testclient import TestClient
from gateway import config, forward
gateway = importlib.import_module("gateway.app")
PATH = "/api/v1/hairline/generate-52"
CLIENT_HEADERS = {"X-API-Token": "yonghe1999"}
@pytest.mark.parametrize("headers", [
{}, {"X-API-Token": ""}, {"X-API-Token": "wrong"},
{"X-API-Token": "yonghe1999 "}, {"X-Internal-Token": "yonghe1999"},
])
def test_invalid_client_token_is_rejected_before_dispatch(monkeypatch, headers):
monkeypatch.setattr(config, "get_config", lambda: {})
proxy = AsyncMock()
monkeypatch.setattr(gateway, "_proxy", proxy)
client = TestClient(gateway.app)
try:
response = client.post(PATH, headers=headers)
assert response.status_code == 401
body = response.json()
assert body["code"] == 1009 and body["data"] is None
assert body["request_id"].startswith("gw-")
finally:
client.close()
proxy.assert_not_awaited()
def test_custom_token_replaces_default_only_on_interface52(monkeypatch):
monkeypatch.setattr(config, "get_config", lambda: {"interface52_token": "custom-client-token"})
proxy = AsyncMock(return_value={"code": 0, "data": None})
monkeypatch.setattr(gateway, "_proxy", proxy)
client = TestClient(gateway.app)
try:
assert client.post(PATH, headers=CLIENT_HEADERS).status_code == 401
assert client.post(PATH, headers={"X-API-Token": "custom-client-token"}).status_code == 200
assert client.post("/api/v1/hairline/generate").status_code == 200
finally:
client.close()
assert proxy.await_count == 2
@pytest.mark.parametrize("token", ["", None, 123, []])
def test_invalid_configured_token_cannot_disable_auth(monkeypatch, token):
monkeypatch.setattr(config, "get_config", lambda: {"interface52_token": token})
proxy = AsyncMock()
monkeypatch.setattr(gateway, "_proxy", proxy)
client = TestClient(gateway.app)
try:
assert client.post(PATH, headers=CLIENT_HEADERS).status_code == 401
finally:
client.close()
proxy.assert_not_awaited()
@pytest.mark.parametrize("token", ["", None, 123, []])
def test_startup_rejects_invalid_client_token_configuration(tmp_path, monkeypatch, token):
import json
path = tmp_path / "config.json"
path.write_text(json.dumps({"interface52_token": token}))
monkeypatch.setattr(config, "CONFIG_PATH", path)
monkeypatch.setattr(config, "_config_cache", None)
with pytest.raises(ValueError, match="interface52_token"):
config.load_config()
def test_existing_config_gets_default_client_token(tmp_path, monkeypatch):
import json
path = tmp_path / "config.json"
path.write_text(json.dumps({"workers": ["http://worker"], "shared_password": "test-internal-token",
"static_dir": str(tmp_path)}))
monkeypatch.setattr(config, "CONFIG_PATH", path)
monkeypatch.setattr(config, "_config_cache", None)
assert config.load_config()["interface52_token"] == "yonghe1999"
def image_bytes(fmt):
buf = io.BytesIO()
Image.new("RGB", (6, 8), (127, 127, 127)).save(buf, format=fmt)
return buf.getvalue()
@pytest.fixture
def proxy_setup(tmp_path, monkeypatch):
output = tmp_path / "annotations"
output.mkdir()
cfg = {"shared_password": "private-gateway-token", "public_base_url": "http://testserver",
"static_dir": str(output), "dispatch": {"request_timeout_seconds": 600, "max_retries": 0}}
monkeypatch.setattr(config, "get_config", lambda: cfg)
acquire = AsyncMock(return_value=True)
release = Mock()
monkeypatch.setattr(forward, "acquire_global_slot", acquire)
monkeypatch.setattr(forward, "release_global_slot", release)
monkeypatch.setattr(forward, "acquire_worker", AsyncMock(return_value=SimpleNamespace(url="http://worker")))
release_worker = AsyncMock()
monkeypatch.setattr(forward, "release_worker", release_worker)
unhealthy = AsyncMock()
monkeypatch.setattr(forward, "mark_worker_unhealthy", unhealthy)
static = next(route.app for route in gateway.app.routes if getattr(route, "name", None) == "static")
monkeypatch.setattr(static, "all_directories", [str(tmp_path), *static.all_directories])
return cfg, acquire, release, release_worker, unhealthy
@pytest.mark.parametrize("path", ["/api/v1/hairline/generate", PATH])
def test_hairline_routes_forward_to_their_own_worker_path(monkeypatch, path):
monkeypatch.setattr(config, "get_config", lambda: {})
proxy = AsyncMock(return_value={"code": 0, "data": None})
monkeypatch.setattr(gateway, "_proxy", proxy)
client = TestClient(gateway.app, client=("203.0.113.9", 50000))
try:
assert client.post(path, headers=CLIENT_HEADERS if path == PATH else {}).json() == {"code": 0, "data": None}
finally:
client.close()
proxy.assert_awaited_once()
assert proxy.await_args.args[1] == path
@pytest.mark.parametrize("generate_grow_image", [False, True])
def test_interface52_raw_forwarding_and_served_image_urls(proxy_setup, monkeypatch, generate_grow_image):
cfg, acquire, release, release_worker, unhealthy = proxy_setup
png, jpg = image_bytes("PNG"), image_bytes("JPEG")
metadata = {"coordinate_space": "processed_image", "width": 6, "height": 8,
"framing": {"status": "reference_crop", "axis_x_error_px": 0},
"height_rule": {"target_center": {"x": 3.0, "y": 2.0}},
"guide_landmarks": {"9": {"x": 3.0, "y": 3.0}},
"best_hairline_center_point": {"x": 3, "y": 2},
"high_hairline_center_point": None, "low_hairline_center_point": None,
"face_measure": None}
worker_json = {"code": 0, "data": {**metadata,
"input_image_base64": base64.b64encode(jpg).decode(),
"original_image_base64": base64.b64encode(jpg).decode(),
"hairline_images": [{"hairline_type": "ellipse", "order": 1,
"height_placement": {"target_error_px": 0.01},
"image_middle_base64": base64.b64encode(png).decode(),
"image_high_base64": None, "image_low_base64": None,
"grown_image_base64": base64.b64encode(jpg).decode() if generate_grow_image else None}]}}
calls = []
def worker(request):
calls.append(request)
return httpx.Response(200, json=worker_json)
upstream = httpx.AsyncClient(transport=httpx.MockTransport(worker))
monkeypatch.setattr(forward, "get_client", lambda: upstream)
fields = {"gender": "male", "hair_style": "1,3", "normalize_image": "true",
"generate_grow_image": str(generate_grow_image).lower(), "use_mask": "false",
"prompt": "test prompt"}
source = httpx.Request("POST", "http://testserver" + PATH,
files={"image_file": ("face.png", png, "image/png")}, data=fields)
raw = source.read()
client = TestClient(gateway.app, client=("203.0.113.9", 50000))
try:
result = client.post(PATH, content=raw, headers={
**CLIENT_HEADERS,
"Content-Type": source.headers["content-type"], "X-Internal-Token": "untrusted-client-token"})
assert result.status_code == 200
data = result.json()["data"]
assert {key: data[key] for key in metadata} == metadata
item = data["hairline_images"][0]
assert item["image_high_base64"] is None and item["image_low_base64"] is None
assert "image_high_url" not in item and "image_low_url" not in item
assert item["height_placement"] == {"target_error_px": 0.01}
for key, expected in [("input_image_url", jpg), ("original_image_url", jpg)]:
assert client.get(data[key]).content == expected
assert data[key].endswith(".jpg")
assert client.get(item["image_middle_url"]).content == png
assert item["image_middle_url"].endswith(".png")
if generate_grow_image:
assert client.get(item["grown_image_url"]).content == jpg
else:
assert item["grown_image_base64"] is None and "grown_image_url" not in item
finally:
client.close()
asyncio.run(upstream.aclose())
assert len(calls) == 1
assert str(calls[0].url) == "http://worker" + PATH
assert calls[0].content == raw
assert calls[0].headers["content-type"] == source.headers["content-type"]
assert calls[0].headers["x-internal-token"] == cfg["shared_password"]
assert calls[0].headers.get("x-forwarded-for") is None
assert calls[0].headers.get("x-api-token") is None
acquire.assert_awaited_once_with(cfg, 0)
release.assert_called_once()
release_worker.assert_awaited_once()
unhealthy.assert_not_awaited()
def test_worker_ip_rejection_is_preserved(proxy_setup, monkeypatch):
_, _, release, release_worker, unhealthy = proxy_setup
body = {"code": 1010, "message": "来源IP不在接口白名单", "data": None}
upstream = httpx.AsyncClient(transport=httpx.MockTransport(lambda request: httpx.Response(403, json=body)))
monkeypatch.setattr(forward, "get_client", lambda: upstream)
client = TestClient(gateway.app)
try:
response = client.post(PATH, headers=CLIENT_HEADERS, data={"gender": "male", "hair_style": "1"})
assert response.status_code == 403 and response.json() == body
finally:
client.close()
asyncio.run(upstream.aclose())
release.assert_called_once()
release_worker.assert_awaited_once()
unhealthy.assert_not_awaited()
def test_public_interface52_page_has_no_internal_password():
client = TestClient(gateway.app)
try:
page = client.get("/static/test_interface52.html")
assert page.status_code == 200
assert PATH in page.text and "X-Internal-Token" not in page.text
assert 'id="apiToken"' in page.text and "X-API-Token" in page.text
assert "localhost:8187" not in page.text
assert "test_interface52_compare.html" not in page.text
assert client.get("/static/hairline_reference_box.js").status_code == 200
finally:
client.close()