Skip to content

Commit 1405c14

Browse files
committed
test(server): cover peer-proxy blank-Authorization regression
Spins up a recording stub hub (FastAPI) and a real hub A peering it. Calls hub A without an Authorization header; asserts the stub receives NO authorization key (not present with a blank value). Mutation-verified: the pre-fix dict always carries `authorization: ""`, causing the assertion to fire.
1 parent f265783 commit 1405c14

1 file changed

Lines changed: 117 additions & 0 deletions

File tree

tests/test_peer_proxy_auth.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Peer-proxy Authorization header regression.
2+
3+
When a client calls hub A without an api_key (anonymous request), and hub A
4+
peer-routes the request to hub B, it must NOT send `Authorization: ""` to
5+
hub B. Sending an empty-value Authorization header is malformed HTTP and
6+
many proxies / nginx / uvicorn front-ends reject it with 400.
7+
8+
The fix: omit the Authorization header entirely when api_key is empty.
9+
"""
10+
11+
import asyncio
12+
import json
13+
import socket
14+
import threading
15+
import time
16+
from typing import Any
17+
18+
import pytest
19+
20+
try:
21+
import fastapi
22+
import httpx
23+
import uvicorn
24+
from fastapi import FastAPI, Request
25+
DEPS_AVAILABLE = True
26+
except ImportError:
27+
DEPS_AVAILABLE = False
28+
29+
if DEPS_AVAILABLE:
30+
from zhub.server import create_app
31+
from zhub import publish
32+
33+
34+
def _free_port() -> int:
35+
with socket.socket() as s:
36+
s.bind(("", 0))
37+
return s.getsockname()[1]
38+
39+
40+
def _wait(port: int) -> None:
41+
for _ in range(40):
42+
try:
43+
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
44+
return
45+
except OSError:
46+
time.sleep(0.1)
47+
48+
49+
@pytest.mark.asyncio
50+
async def test_peer_proxy_omits_auth_header_when_no_key():
51+
"""Hub A peer-routes to a recording stub; no client api_key →
52+
Authorization header must be absent (not present with blank value)."""
53+
if not DEPS_AVAILABLE:
54+
pytest.skip("fastapi/uvicorn/httpx not installed")
55+
56+
received_headers: dict[str, Any] = {}
57+
stub_port = _free_port()
58+
hub_port = _free_port()
59+
60+
# --- tiny recording stub that masquerades as a peer hub ---------------
61+
stub_app = FastAPI()
62+
63+
@stub_app.get("/registry")
64+
async def registry():
65+
# Advertise a publisher so _find_peer_for finds it here.
66+
return [{"name": "remote-bot", "description": "on stub", "public": True}]
67+
68+
@stub_app.post("/remote-bot/v1/chat/completions")
69+
async def chat(request: Request):
70+
received_headers.update(dict(request.headers))
71+
return {"choices": [{"message": {"role": "assistant", "content": "ok"}}]}
72+
73+
def run_stub():
74+
cfg = uvicorn.Config(stub_app, host="127.0.0.1", port=stub_port,
75+
log_level="warning")
76+
asyncio.run(uvicorn.Server(cfg).serve())
77+
78+
# --- real hub A that peers the stub -----------------------------------
79+
import os
80+
env_backup = os.environ.get("ZHUB_PEERS")
81+
os.environ["ZHUB_PEERS"] = f"http://127.0.0.1:{stub_port}"
82+
83+
def run_hub():
84+
cfg = uvicorn.Config(create_app(), host="127.0.0.1", port=hub_port,
85+
log_level="warning")
86+
asyncio.run(uvicorn.Server(cfg).serve())
87+
88+
threading.Thread(target=run_stub, daemon=True).start()
89+
_wait(stub_port)
90+
threading.Thread(target=run_hub, daemon=True).start()
91+
_wait(hub_port)
92+
93+
# Restore env (other tests should not be affected)
94+
if env_backup is None:
95+
os.environ.pop("ZHUB_PEERS", None)
96+
else:
97+
os.environ["ZHUB_PEERS"] = env_backup
98+
99+
# Request without any Authorization header → api_key_header == ""
100+
async with httpx.AsyncClient() as client:
101+
resp = await client.post(
102+
f"http://127.0.0.1:{hub_port}/remote-bot/v1/chat/completions",
103+
json={
104+
"model": "gpt-4o",
105+
"messages": [{"role": "user", "content": "hi"}],
106+
},
107+
# Deliberately no Authorization header
108+
)
109+
110+
# Hub A may respond with the stub's body or an error — what we care
111+
# about is what was sent upstream.
112+
auth_value = received_headers.get("authorization", "MISSING")
113+
assert auth_value == "MISSING", (
114+
f"Hub A forwarded a blank/present Authorization header to the peer: "
115+
f"'authorization: {auth_value}'. "
116+
"When the client provides no api_key the header must be OMITTED."
117+
)

0 commit comments

Comments
 (0)