fix: cover API auth guard regressions

Maintainer edit: restore CI by updating serve/onboard tests, add auth/config coverage, and keep auth failures on the OpenAI-compatible error shape.
This commit is contained in:
chengyongru
2026-07-01 13:09:49 +08:00
committed by Xubin Ren
parent 56443ac6e2
commit ed48325346
6 changed files with 75 additions and 9 deletions
+1
View File
@@ -107,6 +107,7 @@ File operations have path traversal protection, but:
**API Calls:**
- All external API calls use HTTPS by default
- Timeouts are configured to prevent hanging requests
- The OpenAI-compatible API server must set `api.api_key` when binding to `0.0.0.0` or `::`; otherwise startup fails to prevent unauthenticated network access
- Consider using a firewall to restrict outbound connections if needed
**WhatsApp:**
+2 -7
View File
@@ -421,14 +421,9 @@ def create_app(
return await handler(request)
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return web.json_response(
{"error": "Missing Authorization header. Use: Bearer <api_key>"},
status=401,
)
return _error_json(401, "Missing Authorization header. Use: Bearer <api_key>")
if not hmac.compare_digest(auth[len("Bearer "):], api_key):
return web.json_response(
{"error": "Invalid API key"}, status=401,
)
return _error_json(401, "Invalid API key")
return await handler(request)
app.middlewares.append(auth_middleware)
+2 -1
View File
@@ -853,10 +853,11 @@ class TestApiServerRegistration:
config = Config()
from nanobot.config.schema import ApiConfig
new_api = ApiConfig(host="0.0.0.0", port=9999)
new_api = ApiConfig(host="0.0.0.0", port=9999, api_key="secret")
_SETTINGS_SETTER["API Server"](config, new_api)
assert config.api.host == "0.0.0.0"
assert config.api.port == 9999
assert config.api.api_key == "secret"
class TestMainMenuUpdate:
+37 -1
View File
@@ -1561,10 +1561,16 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
async def close_mcp(self) -> None:
return None
def _fake_create_app(agent_loop, model_name: str, request_timeout: float):
def _fake_create_app(
agent_loop,
model_name: str,
request_timeout: float,
api_key: str = "",
):
seen["agent_loop"] = agent_loop
seen["model_name"] = model_name
seen["request_timeout"] = request_timeout
seen["api_key"] = api_key
return _FakeApiApp()
def _fake_run_app(api_app, host: str, port: int, print):
@@ -2507,6 +2513,7 @@ def test_serve_uses_api_config_defaults_and_workspace_override(
assert seen["host"] == "127.0.0.2"
assert seen["port"] == 18900
assert seen["request_timeout"] == 45.0
assert seen["api_key"] == ""
def test_serve_cli_options_override_api_config(monkeypatch, tmp_path: Path) -> None:
@@ -2538,6 +2545,35 @@ def test_serve_cli_options_override_api_config(monkeypatch, tmp_path: Path) -> N
assert seen["host"] == "127.0.0.1"
assert seen["port"] == 18901
assert seen["request_timeout"] == 46.0
assert seen["api_key"] == ""
def test_serve_passes_configured_api_key(monkeypatch, tmp_path: Path) -> None:
config_file = _write_instance_config(tmp_path)
config = Config()
config.api.api_key = " secret "
seen: dict[str, object] = {}
_patch_serve_runtime(monkeypatch, config, seen)
result = runner.invoke(app, ["serve", "--config", str(config_file)])
assert result.exit_code == 0
assert seen["api_key"] == "secret"
def test_serve_rejects_wildcard_host_without_api_key(monkeypatch, tmp_path: Path) -> None:
config_file = _write_instance_config(tmp_path)
config = Config()
seen: dict[str, object] = {}
_patch_serve_runtime(monkeypatch, config, seen)
result = runner.invoke(app, ["serve", "--config", str(config_file), "--host", "0.0.0.0"])
assert result.exit_code == 1
assert "api_key is not set" in result.stdout
assert "api_app" not in seen
def test_channels_login_requires_channel_name() -> None:
+14
View File
@@ -3,6 +3,7 @@ import json
import pytest
from nanobot.config.loader import load_config
from nanobot.config.schema import ApiConfig
def test_load_config_missing_file_uses_defaults(tmp_path) -> None:
@@ -28,3 +29,16 @@ def test_load_config_invalid_schema_fails_fast(tmp_path) -> None:
with pytest.raises(ValueError, match="Failed to load config"):
load_config(config_path)
@pytest.mark.parametrize("host", ["0.0.0.0", "::"])
def test_api_config_requires_key_for_wildcard_hosts(host: str) -> None:
with pytest.raises(ValueError, match="api_key is not set"):
ApiConfig(host=host)
def test_api_config_allows_wildcard_host_with_key() -> None:
config = ApiConfig(host="0.0.0.0", api_key="secret")
assert config.host == "0.0.0.0"
assert config.api_key == "secret"
+19
View File
@@ -108,6 +108,25 @@ async def test_missing_messages_returns_400(aiohttp_client, app) -> None:
assert resp.status == 400
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_api_key_protects_api_routes_but_not_health(aiohttp_client, mock_agent) -> None:
app = create_app(mock_agent, model_name="test-model", api_key="secret")
client = await aiohttp_client(app)
health = await client.get("/health")
missing = await client.get("/v1/models")
wrong = await client.get("/v1/models", headers={"Authorization": "Bearer wrong"})
ok = await client.get("/v1/models", headers={"Authorization": "Bearer secret"})
assert health.status == 200
assert missing.status == 401
assert wrong.status == 401
assert ok.status == 200
assert (await missing.json())["error"]["message"].startswith("Missing Authorization")
assert (await wrong.json())["error"]["message"] == "Invalid API key"
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_no_user_message_returns_400(aiohttp_client, app) -> None: