Expose capability discovery plus admin-only config and request inspection endpoints so clients and operators can understand gateway behavior without reading code. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
153 lines
5.6 KiB
Python
153 lines
5.6 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import sys
|
|
import types
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from fastapi import HTTPException
|
|
from fastapi.testclient import TestClient
|
|
from starlette.requests import Request
|
|
|
|
from app.auth import AnthropicAuthError, require_anthropic_key, require_bearer, require_metrics_access
|
|
from app.concurrency import BackpressureRejected, InFlightGuard
|
|
|
|
_playwright = types.ModuleType("playwright")
|
|
_playwright_async = types.ModuleType("playwright.async_api")
|
|
|
|
|
|
class _StubPlaywrightTimeoutError(Exception):
|
|
pass
|
|
|
|
|
|
async def _stub_async_playwright():
|
|
raise RuntimeError("playwright is stubbed in unit tests")
|
|
|
|
|
|
_playwright_async.TimeoutError = _StubPlaywrightTimeoutError
|
|
_playwright_async.async_playwright = _stub_async_playwright
|
|
sys.modules.setdefault("playwright", _playwright)
|
|
sys.modules.setdefault("playwright.async_api", _playwright_async)
|
|
|
|
import app.main as main
|
|
|
|
|
|
def _req(headers: dict[str, str] | None = None) -> Request:
|
|
pairs = []
|
|
for k, v in (headers or {}).items():
|
|
pairs.append((k.lower().encode("latin-1"), v.encode("latin-1")))
|
|
scope = {
|
|
"type": "http",
|
|
"http_version": "1.1",
|
|
"method": "GET",
|
|
"scheme": "http",
|
|
"path": "/x",
|
|
"raw_path": b"/x",
|
|
"query_string": b"",
|
|
"headers": pairs,
|
|
"client": ("test", 1),
|
|
"server": ("test", 80),
|
|
"root_path": "",
|
|
}
|
|
return Request(scope)
|
|
|
|
|
|
class AuthAndConcurrencyTests(unittest.IsolatedAsyncioTestCase):
|
|
def test_require_bearer_accepts_valid_token(self) -> None:
|
|
request = _req({"authorization": "Bearer good"})
|
|
require_bearer(request, ["good"])
|
|
|
|
def test_require_bearer_rejects_invalid_token(self) -> None:
|
|
request = _req({"authorization": "Bearer bad"})
|
|
with self.assertRaises(HTTPException) as ctx:
|
|
require_bearer(request, ["good"])
|
|
self.assertEqual(ctx.exception.status_code, 401)
|
|
self.assertEqual(ctx.exception.detail["error"]["code"], "invalid_api_key")
|
|
|
|
def test_require_anthropic_key_accepts_x_api_key_or_bearer(self) -> None:
|
|
request_x = _req({"x-api-key": "k1"})
|
|
require_anthropic_key(request_x, ["k1"])
|
|
|
|
request_b = _req({"authorization": "Bearer k2"})
|
|
require_anthropic_key(request_b, ["k2"])
|
|
|
|
def test_require_anthropic_key_raises_on_missing(self) -> None:
|
|
request = _req()
|
|
with self.assertRaises(AnthropicAuthError) as ctx:
|
|
require_anthropic_key(request, ["k"])
|
|
self.assertEqual(ctx.exception.status_code, 401)
|
|
self.assertEqual(ctx.exception.error_type, "authentication_error")
|
|
|
|
def test_require_metrics_access_503_when_no_tokens_configured(self) -> None:
|
|
request = _req({"authorization": "Bearer any"})
|
|
with self.assertRaises(HTTPException) as ctx:
|
|
require_metrics_access(request, api_keys=[], metrics_token="", public=False)
|
|
self.assertEqual(ctx.exception.status_code, 503)
|
|
self.assertEqual(ctx.exception.detail["error"]["code"], "metrics_disabled")
|
|
|
|
async def test_inflight_guard_unlimited_and_release_idempotent(self) -> None:
|
|
guard = InFlightGuard(max_in_flight=0, queue_timeout_sec=0.01)
|
|
ticket = await guard.try_acquire()
|
|
self.assertEqual(guard.in_flight, 1)
|
|
ticket.release()
|
|
ticket.release()
|
|
self.assertEqual(guard.in_flight, 0)
|
|
self.assertEqual(guard.accepted_total, 1)
|
|
|
|
async def test_inflight_guard_rejects_when_queue_timeout(self) -> None:
|
|
guard = InFlightGuard(max_in_flight=1, queue_timeout_sec=0.01)
|
|
first = await guard.try_acquire()
|
|
with self.assertRaises(BackpressureRejected):
|
|
await guard.try_acquire()
|
|
self.assertEqual(guard.rejected_total, 1)
|
|
first.release()
|
|
self.assertEqual(guard.in_flight, 0)
|
|
|
|
|
|
class DebugRequestRecordingTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
main._DEBUG_REQUEST_LOG.clear()
|
|
|
|
def test_redacts_sensitive_fields_and_data_urls(self) -> None:
|
|
body = {
|
|
"authorization": "Bearer abc",
|
|
"x-api-key": "secret",
|
|
"session_bundle": "very-secret",
|
|
"images": ["data:image/png;base64,ABC"],
|
|
"tool": {"args": "x" * 3000},
|
|
}
|
|
redacted = main._redact_debug_value((), body)
|
|
|
|
self.assertEqual(redacted["authorization"], "***")
|
|
self.assertEqual(redacted["x-api-key"], "***")
|
|
self.assertEqual(redacted["session_bundle"], "***")
|
|
self.assertEqual(redacted["images"][0], "[redacted-data-url]")
|
|
self.assertIn("[truncated]", redacted["tool"]["args"])
|
|
|
|
def test_internal_debug_requests_requires_admin_and_returns_items(self) -> None:
|
|
with patch.object(main.settings, "api_keys", ["k1"]), patch.object(main.settings, "admin_token", "admin-1"):
|
|
client = TestClient(main.app)
|
|
req_payload = {
|
|
"model": "org_auto",
|
|
"messages": [{"role": "user", "content": "hello"}],
|
|
}
|
|
main._record_debug_request("openai", "/v1/chat/completions", req_payload, _req({"x-request-id": "req-1"}))
|
|
|
|
denied = client.get("/internal/debug/requests")
|
|
self.assertEqual(denied.status_code, 401)
|
|
|
|
ok = client.get(
|
|
"/internal/debug/requests?limit=1",
|
|
headers={"Authorization": "Bearer admin-1"},
|
|
)
|
|
self.assertEqual(ok.status_code, 200)
|
|
data = ok.json()
|
|
self.assertTrue(data["ok"])
|
|
self.assertEqual(data["count"], 1)
|
|
self.assertEqual(data["items"][0]["protocol"], "openai")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|