From e86133c434bc4ffc68c4449825558d9018f2203b Mon Sep 17 00:00:00 2001 From: hamb1y Date: Fri, 3 Jul 2026 00:35:06 +0530 Subject: [PATCH] fix: require api auth in server factory --- nanobot/api/server.py | 4 +-- tests/test_api_attachment.py | 30 ++++++++++++------ tests/test_api_stream.py | 30 ++++++++++++------ tests/test_openai_api.py | 60 ++++++++++++++++++++++++++++-------- 4 files changed, 90 insertions(+), 34 deletions(-) diff --git a/nanobot/api/server.py b/nanobot/api/server.py index d746b7cf..e41f8a71 100644 --- a/nanobot/api/server.py +++ b/nanobot/api/server.py @@ -414,11 +414,11 @@ def create_app( @web.middleware async def auth_middleware(request: web.Request, handler) -> web.StreamResponse: - if not api_key: - return await handler(request) # Allow unauthenticated health checks. if request.path == "/health": return await handler(request) + if not api_key: + return _error_json(401, "API key is not configured") auth = request.headers.get("Authorization", "") if not auth.startswith("Bearer "): return _error_json(401, "Missing Authorization header. Use: Bearer ") diff --git a/tests/test_api_attachment.py b/tests/test_api_attachment.py index 3cdbc847..63852db4 100644 --- a/tests/test_api_attachment.py +++ b/tests/test_api_attachment.py @@ -26,6 +26,9 @@ except ImportError: pytest_plugins = ("pytest_asyncio",) +API_KEY = "secret" +AUTH_HEADERS = {"Authorization": f"Bearer {API_KEY}"} + def _make_mock_agent(response_text: str = "mock response") -> MagicMock: agent = MagicMock() @@ -43,7 +46,7 @@ def mock_agent(): @pytest.fixture def app(mock_agent): - return create_app(mock_agent, model_name="test-model", request_timeout=10.0) + return create_app(mock_agent, model_name="test-model", request_timeout=10.0, api_key=API_KEY) @pytest_asyncio.fixture @@ -192,7 +195,7 @@ async def test_multipart_upload_saves_file(aiohttp_client, mock_agent, tmp_path) os.chdir(tmp_path) try: - app = create_app(mock_agent, model_name="m") + app = create_app(mock_agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) file_data = b"test file content" @@ -200,6 +203,7 @@ async def test_multipart_upload_saves_file(aiohttp_client, mock_agent, tmp_path) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, data={"message": "analyze this", "files": data}, ) assert resp.status == 200 @@ -219,7 +223,7 @@ async def test_multipart_multiple_files(aiohttp_client, mock_agent, tmp_path) -> os.chdir(tmp_path) try: - app = create_app(mock_agent, model_name="m") + app = create_app(mock_agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) # Note: aiohttp test client has limited multipart support @@ -229,6 +233,7 @@ async def test_multipart_multiple_files(aiohttp_client, mock_agent, tmp_path) -> resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, data={"message": "analyze", "files": data}, ) assert resp.status == 200 @@ -245,7 +250,7 @@ async def test_multipart_file_size_limit(aiohttp_client, mock_agent, tmp_path) - os.chdir(tmp_path) try: - app = create_app(mock_agent, model_name="m") + app = create_app(mock_agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) # Create a file larger than 10MB @@ -254,6 +259,7 @@ async def test_multipart_file_size_limit(aiohttp_client, mock_agent, tmp_path) - resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, data={"message": "analyze", "files": data}, ) assert resp.status == 413 @@ -270,7 +276,7 @@ async def test_multipart_defaults_text_when_missing(aiohttp_client, mock_agent, os.chdir(tmp_path) try: - app = create_app(mock_agent, model_name="m") + app = create_app(mock_agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) file_data = b"content" @@ -278,6 +284,7 @@ async def test_multipart_defaults_text_when_missing(aiohttp_client, mock_agent, resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, data={"files": data}, ) assert resp.status == 200 @@ -296,7 +303,7 @@ async def test_multipart_with_session_id(aiohttp_client, mock_agent, tmp_path) - os.chdir(tmp_path) try: - app = create_app(mock_agent, model_name="m") + app = create_app(mock_agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) file_data = b"content" @@ -304,6 +311,7 @@ async def test_multipart_with_session_id(aiohttp_client, mock_agent, tmp_path) - resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, data={"message": "hello", "session_id": "my-session", "files": data}, ) assert resp.status == 200 @@ -321,10 +329,11 @@ async def test_multipart_with_session_id(aiohttp_client, mock_agent, tmp_path) - @pytest.mark.asyncio async def test_plain_text_backward_compat(aiohttp_client, mock_agent) -> None: """Plain text JSON request (no media) works as before.""" - app = create_app(mock_agent, model_name="m") + app = create_app(mock_agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={"messages": [{"role": "user", "content": "hello world"}]}, ) assert resp.status == 200 @@ -344,7 +353,7 @@ async def test_json_base64_image_upload(aiohttp_client, mock_agent, tmp_path) -> os.chdir(tmp_path) try: - app = create_app(mock_agent, model_name="m") + app = create_app(mock_agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) # Use valid base64 for a tiny PNG (1x1 transparent pixel) @@ -352,6 +361,7 @@ async def test_json_base64_image_upload(aiohttp_client, mock_agent, tmp_path) -> resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={ "messages": [ { @@ -471,7 +481,7 @@ async def test_docx_upload_passes_media_path(aiohttp_client, tmp_path) -> None: os.chdir(tmp_path) try: - app = create_app(agent, model_name="m") + app = create_app(agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) from docx import Document @@ -486,7 +496,7 @@ async def test_docx_upload_passes_media_path(aiohttp_client, tmp_path) -> None: data.add_field("files", buf.getvalue(), filename="report.docx", content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document") - resp = await client.post("/v1/chat/completions", data=data) + resp = await client.post("/v1/chat/completions", headers=AUTH_HEADERS, data=data) assert resp.status == 200 call_kwargs = agent.process_direct.call_args.kwargs assert call_kwargs["content"] == "summarize the report" diff --git a/tests/test_api_stream.py b/tests/test_api_stream.py index f23339bf..f3487e4f 100644 --- a/tests/test_api_stream.py +++ b/tests/test_api_stream.py @@ -24,6 +24,9 @@ except ImportError: pytest_plugins = ("pytest_asyncio",) +API_KEY = "secret" +AUTH_HEADERS = {"Authorization": f"Bearer {API_KEY}"} + # --------------------------------------------------------------------------- # Unit tests for SSE helpers @@ -101,11 +104,12 @@ async def aiohttp_client(): async def test_stream_true_returns_sse(aiohttp_client) -> None: """stream=true should return text/event-stream with SSE chunks.""" agent = _make_streaming_agent(["Hello", " world"]) - app = create_app(agent, model_name="test-model") + app = create_app(agent, model_name="test-model", api_key=API_KEY) client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={"messages": [{"role": "user", "content": "hi"}], "stream": True}, ) assert resp.status == 200 @@ -136,11 +140,12 @@ async def test_stream_false_returns_json(aiohttp_client) -> None: agent.close_mcp = AsyncMock() agent._last_usage = {} - app = create_app(agent, model_name="m") + app = create_app(agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={"messages": [{"role": "user", "content": "hi"}], "stream": False}, ) assert resp.status == 200 @@ -159,11 +164,12 @@ async def test_stream_default_is_false(aiohttp_client) -> None: agent.close_mcp = AsyncMock() agent._last_usage = {} - app = create_app(agent, model_name="m") + app = create_app(agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={"messages": [{"role": "user", "content": "hi"}]}, ) assert resp.status == 200 @@ -176,11 +182,12 @@ async def test_stream_default_is_false(aiohttp_client) -> None: async def test_stream_sse_chunk_ids_are_consistent(aiohttp_client) -> None: """All SSE chunks in a single stream should share the same id.""" agent = _make_streaming_agent(["A", "B", "C"]) - app = create_app(agent, model_name="m") + app = create_app(agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={"messages": [{"role": "user", "content": "go"}], "stream": True}, ) body = await resp.text() @@ -214,11 +221,12 @@ async def test_stream_passes_on_stream_callbacks(aiohttp_client) -> None: agent.close_mcp = AsyncMock() agent._last_usage = {} - app = create_app(agent, model_name="m") + app = create_app(agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={"messages": [{"role": "user", "content": "hi"}], "stream": True}, ) assert resp.status == 200 @@ -247,11 +255,12 @@ async def test_stream_segment_end_does_not_close_sse(aiohttp_client) -> None: agent.close_mcp = AsyncMock() agent._last_usage = {} - app = create_app(agent, model_name="m") + app = create_app(agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={"messages": [{"role": "user", "content": "use a tool"}], "stream": True}, ) @@ -286,11 +295,12 @@ async def test_stream_uses_final_response_when_no_deltas(aiohttp_client) -> None agent.close_mcp = AsyncMock() agent._last_usage = {} - app = create_app(agent, model_name="m") + app = create_app(agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={"messages": [{"role": "user", "content": "hi"}], "stream": True}, ) @@ -328,11 +338,12 @@ async def test_stream_with_session_id(aiohttp_client) -> None: agent.close_mcp = AsyncMock() agent._last_usage = {} - app = create_app(agent, model_name="m") + app = create_app(agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={ "messages": [{"role": "user", "content": "hi"}], "stream": True, @@ -357,11 +368,12 @@ async def test_streaming_backend_failure_does_not_emit_success_terminator(aiohtt agent.close_mcp = AsyncMock() agent._last_usage = {} - app = create_app(agent, model_name="m") + app = create_app(agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={"messages": [{"role": "user", "content": "hi"}], "stream": True}, ) diff --git a/tests/test_openai_api.py b/tests/test_openai_api.py index b6eb44b9..5e652cb6 100644 --- a/tests/test_openai_api.py +++ b/tests/test_openai_api.py @@ -27,6 +27,9 @@ except ImportError: pytest_plugins = ("pytest_asyncio",) +API_KEY = "secret" +AUTH_HEADERS = {"Authorization": f"Bearer {API_KEY}"} + def _make_mock_agent(response_text: str = "mock response") -> MagicMock: agent = MagicMock() @@ -44,7 +47,7 @@ def mock_agent(): @pytest.fixture def app(mock_agent): - return create_app(mock_agent, model_name="test-model", request_timeout=10.0) + return create_app(mock_agent, model_name="test-model", request_timeout=10.0, api_key=API_KEY) @pytest_asyncio.fixture @@ -104,20 +107,20 @@ def test_chat_completion_response_preserves_provider_total_usage() -> None: @pytest.mark.asyncio async def test_missing_messages_returns_400(aiohttp_client, app) -> None: client = await aiohttp_client(app) - resp = await client.post("/v1/chat/completions", json={"model": "test"}) + resp = await client.post("/v1/chat/completions", headers=AUTH_HEADERS, json={"model": "test"}) 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") + app = create_app(mock_agent, model_name="test-model", api_key=API_KEY) 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"}) + ok = await client.get("/v1/models", headers=AUTH_HEADERS) assert health.status == 200 assert missing.status == 401 @@ -127,12 +130,33 @@ async def test_api_key_protects_api_routes_but_not_health(aiohttp_client, mock_a 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_api_routes_fail_closed_without_configured_api_key(aiohttp_client, mock_agent) -> None: + app = create_app(mock_agent, model_name="test-model") + client = await aiohttp_client(app) + + health = await client.get("/health") + models = await client.get("/v1/models") + chat = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "hello"}]}, + ) + + assert health.status == 200 + assert models.status == 401 + assert chat.status == 401 + assert (await models.json())["error"]["message"] == "API key is not configured" + mock_agent.process_direct.assert_not_called() + + @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: client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={"messages": [{"role": "system", "content": "you are a bot"}]}, ) assert resp.status == 400 @@ -144,6 +168,7 @@ async def test_stream_true_returns_sse(aiohttp_client, app) -> None: client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={"messages": [{"role": "user", "content": "hello"}], "stream": True}, ) assert resp.status == 200 @@ -220,10 +245,11 @@ async def test_single_user_message_must_have_user_role() -> None: @pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") @pytest.mark.asyncio async def test_successful_request_uses_fixed_api_session(aiohttp_client, mock_agent) -> None: - app = create_app(mock_agent, model_name="test-model") + app = create_app(mock_agent, model_name="test-model", api_key=API_KEY) client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={"messages": [{"role": "user", "content": "hello"}]}, ) assert resp.status == 200 @@ -254,15 +280,17 @@ async def test_followup_requests_share_same_session_key(aiohttp_client) -> None: agent.close_mcp = AsyncMock() agent._last_usage = {} - app = create_app(agent, model_name="m") + app = create_app(agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) r1 = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={"messages": [{"role": "user", "content": "first"}]}, ) r2 = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={"messages": [{"role": "user", "content": "second"}]}, ) @@ -292,12 +320,13 @@ async def test_fixed_session_requests_are_serialized(aiohttp_client) -> None: agent.close_mcp = AsyncMock() agent._last_usage = {} - app = create_app(agent, model_name="m") + app = create_app(agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) async def send(msg: str): return await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={"messages": [{"role": "user", "content": msg}]}, ) @@ -318,7 +347,7 @@ async def test_fixed_session_requests_are_serialized(aiohttp_client) -> None: @pytest.mark.asyncio async def test_models_endpoint(aiohttp_client, app) -> None: client = await aiohttp_client(app) - resp = await client.get("/v1/models") + resp = await client.get("/v1/models", headers=AUTH_HEADERS) assert resp.status == 200 body = await resp.json() assert body["object"] == "list" @@ -338,10 +367,11 @@ async def test_health_endpoint(aiohttp_client, app) -> None: @pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") @pytest.mark.asyncio async def test_multimodal_content_extracts_text(aiohttp_client, mock_agent) -> None: - app = create_app(mock_agent, model_name="m") + app = create_app(mock_agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={ "messages": [ { @@ -366,10 +396,11 @@ async def test_multimodal_content_extracts_text(aiohttp_client, mock_agent) -> N @pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") @pytest.mark.asyncio async def test_multimodal_remote_image_url_returns_400(aiohttp_client, mock_agent) -> None: - app = create_app(mock_agent, model_name="m") + app = create_app(mock_agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={ "messages": [ { @@ -407,10 +438,11 @@ async def test_empty_response_retry_then_success(aiohttp_client) -> None: agent.close_mcp = AsyncMock() agent._last_usage = {} - app = create_app(agent, model_name="m") + app = create_app(agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={"messages": [{"role": "user", "content": "hello"}]}, ) assert resp.status == 200 @@ -434,10 +466,11 @@ async def test_empty_response_retry_does_not_duplicate_user_turn(aiohttp_client) agent.close_mcp = AsyncMock() agent._last_usage = {} - app = create_app(agent, model_name="m") + app = create_app(agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={"messages": [{"role": "user", "content": "hello"}]}, ) assert resp.status == 200 @@ -463,10 +496,11 @@ async def test_empty_response_falls_back(aiohttp_client) -> None: agent.close_mcp = AsyncMock() agent._last_usage = {} - app = create_app(agent, model_name="m") + app = create_app(agent, model_name="m", api_key=API_KEY) client = await aiohttp_client(app) resp = await client.post( "/v1/chat/completions", + headers=AUTH_HEADERS, json={"messages": [{"role": "user", "content": "hello"}]}, ) assert resp.status == 200