diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index ae63feb..49f501b 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -156,21 +156,77 @@ def _headers(self, authenticated: bool = True) -> dict[str, str]: return headers def _agent_headers(self) -> dict[str, str]: - """Build headers for agent authentication (API key preferred, then Bearer).""" + """Build headers for agent authentication. + + Uses the same priority as ``_headers()`` — Bearer first, API key + second — so that all endpoints behave consistently. The server + accepts both on every ``/v1/agent*`` endpoint. + """ headers: dict[str, str] = { "Content-Type": "application/json", "Accept": "application/json", } - if self.api_key: - headers["X-API-KEY"] = self.api_key - self._agent_auth_mode = "api_key" - elif self.token: + if self.token: headers["Authorization"] = f"Bearer {self.token}" self._agent_auth_mode = "bearer" + elif self.api_key: + headers["X-API-KEY"] = self.api_key + self._agent_auth_mode = "api_key" else: self._agent_auth_mode = None return headers + def _alt_auth_headers(self) -> dict[str, str] | None: + """Build headers using the alternative credential for a 401 retry. + + If the primary was Bearer, tries API key; if the primary was API key, + tries Bearer. Returns ``None`` when no alternative is available. + """ + headers: dict[str, str] = { + "Content-Type": "application/json", + "Accept": "application/json", + } + if self._agent_auth_mode == "bearer" and self.api_key: + headers["X-API-KEY"] = self.api_key + self._agent_auth_mode = "api_key" + return headers + if self._agent_auth_mode == "api_key" and self.token: + headers["Authorization"] = f"Bearer {self.token}" + self._agent_auth_mode = "bearer" + return headers + return None + + def _agent_request( + self, + method: str, + url: str, + *, + json: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> httpx.Response: + """Execute an agent-authenticated request with automatic alt-credential retry. + + Tries with ``_agent_headers()`` (Bearer preferred, API key fallback). + If the server returns 401 and an alternative credential is available, + retries once with it. This covers both directions: expired Bearer + retried with API key, and stale API key retried with Bearer. + """ + kwargs: dict[str, Any] = {"headers": self._agent_headers(), "timeout": self.timeout} + if json is not None: + kwargs["json"] = json + if params is not None: + kwargs["params"] = params + + response: httpx.Response = httpx.request(method, url, **kwargs) + + if response.status_code == 401: + alt: dict[str, str] | None = self._alt_auth_headers() + if alt is not None: + kwargs["headers"] = alt + response = httpx.request(method, url, **kwargs) + + return response + def _handle_response(self, response: httpx.Response) -> dict[str, Any]: """Parse API response and raise on errors.""" if response.status_code >= 400: @@ -1420,11 +1476,10 @@ def submit_agent_report( payload["is_milestone"] = True if co_authors: payload["co_authors"] = co_authors - response: httpx.Response = httpx.post( + response: httpx.Response = self._agent_request( + "POST", f"{self.api_url}/v1/agent-reports/", json=payload, - headers=self._agent_headers(), - timeout=self.timeout, ) return self._handle_response(response) @@ -1441,21 +1496,19 @@ def submit_agent_health( } if message: payload["message"] = message - response: httpx.Response = httpx.post( + response: httpx.Response = self._agent_request( + "POST", f"{self.api_url}/v1/agent-health/", json=payload, - headers=self._agent_headers(), - timeout=self.timeout, ) return self._handle_response(response) def get_agent_health(self, agent_name: str) -> dict[str, Any]: """GET /v1/agent-health/?agent_name=...""" - response: httpx.Response = httpx.get( + response: httpx.Response = self._agent_request( + "GET", f"{self.api_url}/v1/agent-health/", params={"agent_name": agent_name}, - headers=self._agent_headers(), - timeout=self.timeout, ) return self._handle_response(response) @@ -1474,22 +1527,19 @@ def register_agent_webhook( } if webhook_secret: payload["webhook_secret"] = webhook_secret - response: httpx.Response = httpx.post( + response: httpx.Response = self._agent_request( + "POST", f"{self.api_url}/v1/agent-webhook/", json=payload, - headers=self._agent_headers(), - timeout=self.timeout, ) return self._handle_response(response) def unregister_agent_webhook(self, agent_name: str) -> dict[str, Any]: """DELETE /v1/agent-webhook/""" - response: httpx.Response = httpx.request( + response: httpx.Response = self._agent_request( "DELETE", f"{self.api_url}/v1/agent-webhook/", json={"agent_name": agent_name}, - headers=self._agent_headers(), - timeout=self.timeout, ) return self._handle_response(response) @@ -1512,11 +1562,10 @@ def send_agent_email( } if metadata: payload["metadata"] = metadata - response: httpx.Response = httpx.post( + response: httpx.Response = self._agent_request( + "POST", f"{self.api_url}/v1/agent-email/send/", json=payload, - headers=self._agent_headers(), - timeout=self.timeout, ) return self._handle_response(response) @@ -1543,11 +1592,10 @@ def send_chat_message(self, payload: dict[str, Any]) -> dict[str, Any]: parent and — when ``thread_responses`` was sent — one id per reply, all of which can be fed back in a later call to edit the same message. """ - response: httpx.Response = httpx.post( + response: httpx.Response = self._agent_request( + "POST", f"{self.api_url}/v1/send-message/", json=payload, - headers=self._agent_headers(), - timeout=self.timeout, ) return self._handle_response(response) @@ -1563,11 +1611,10 @@ def open_conversation(self, users_uuids: list[str]) -> dict[str, Any]: the shared agent header logic (``X-API-KEY`` preferred, else the login Bearer token). Returns ``{"channel": ""}``. """ - response: httpx.Response = httpx.post( + response: httpx.Response = self._agent_request( + "POST", f"{self.api_url}/v1/open-conversation/", json={"users_uuids": users_uuids}, - headers=self._agent_headers(), - timeout=self.timeout, ) return self._handle_response(response) @@ -1598,11 +1645,10 @@ def send_agent_message( payload["sender_type"] = sender_type if sender_name: payload["sender_name"] = sender_name - response: httpx.Response = httpx.post( + response: httpx.Response = self._agent_request( + "POST", f"{self.api_url}/v1/agent-messages/", json=payload, - headers=self._agent_headers(), - timeout=self.timeout, ) return self._handle_response(response) @@ -1615,11 +1661,10 @@ def get_agent_messages( params: dict[str, str] = {"agent_name": agent_name} if delivered is not None: params["delivered"] = "true" if delivered else "false" - response: httpx.Response = httpx.get( + response: httpx.Response = self._agent_request( + "GET", f"{self.api_url}/v1/agent-messages/", params=params, - headers=self._agent_headers(), - timeout=self.timeout, ) if response.status_code >= 400: self._handle_response(response) @@ -1635,11 +1680,10 @@ def mark_agent_messages_read( message_ids: list[str], ) -> dict[str, Any]: """PATCH /v1/agent-messages/read/""" - response: httpx.Response = httpx.patch( + response: httpx.Response = self._agent_request( + "PATCH", f"{self.api_url}/v1/agent-messages/read/", json={"message_ids": message_ids}, - headers=self._agent_headers(), - timeout=self.timeout, ) return self._handle_response(response) diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 92a9f59..af9b847 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -793,15 +793,15 @@ def test_submit_agent_report(self, client: DailyBotClient) -> None: mock_response.status_code = 201 mock_response.json.return_value = {"id": 1, "uuid": "abc"} - with patch("httpx.post", return_value=mock_response) as mock_post: + with patch("dailybot_cli.api_client.httpx.request", return_value=mock_response) as mock_req: result: dict[str, Any] = client.submit_agent_report( agent_name="Claude Code", content="Deployed v2", ) - call_kwargs: dict[str, Any] = mock_post.call_args[1] + call_kwargs: dict[str, Any] = mock_req.call_args[1] assert call_kwargs["json"]["agent_name"] == "Claude Code" - assert call_kwargs["headers"]["X-API-KEY"] == "test-api-key" + assert call_kwargs["headers"]["Authorization"] == "Bearer test-token" assert result["id"] == 1 def test_submit_agent_report_with_milestone(self, client: DailyBotClient) -> None: @@ -809,14 +809,14 @@ def test_submit_agent_report_with_milestone(self, client: DailyBotClient) -> Non mock_response.status_code = 201 mock_response.json.return_value = {"id": 2, "is_milestone": True} - with patch("httpx.post", return_value=mock_response) as mock_post: + with patch("dailybot_cli.api_client.httpx.request", return_value=mock_response) as mock_req: result: dict[str, Any] = client.submit_agent_report( agent_name="Claude Code", content="Big feature", is_milestone=True, ) - call_kwargs: dict[str, Any] = mock_post.call_args[1] + call_kwargs: dict[str, Any] = mock_req.call_args[1] assert call_kwargs["json"]["is_milestone"] is True assert result["is_milestone"] is True @@ -825,14 +825,14 @@ def test_submit_agent_report_with_co_authors(self, client: DailyBotClient) -> No mock_response.status_code = 201 mock_response.json.return_value = {"id": 3, "co_authors": [{"name": "Alice"}]} - with patch("httpx.post", return_value=mock_response) as mock_post: + with patch("dailybot_cli.api_client.httpx.request", return_value=mock_response) as mock_req: result: dict[str, Any] = client.submit_agent_report( agent_name="Claude Code", content="Paired work", co_authors=["alice@co.com", "bob@co.com"], ) - call_kwargs: dict[str, Any] = mock_post.call_args[1] + call_kwargs: dict[str, Any] = mock_req.call_args[1] assert call_kwargs["json"]["co_authors"] == ["alice@co.com", "bob@co.com"] assert result["co_authors"] == [{"name": "Alice"}] @@ -841,13 +841,13 @@ def test_submit_agent_report_defaults_omit_new_fields(self, client: DailyBotClie mock_response.status_code = 201 mock_response.json.return_value = {"id": 4} - with patch("httpx.post", return_value=mock_response) as mock_post: + with patch("dailybot_cli.api_client.httpx.request", return_value=mock_response) as mock_req: client.submit_agent_report( agent_name="Claude Code", content="Normal report", ) - call_kwargs: dict[str, Any] = mock_post.call_args[1] + call_kwargs: dict[str, Any] = mock_req.call_args[1] assert "is_milestone" not in call_kwargs["json"] assert "co_authors" not in call_kwargs["json"] @@ -864,7 +864,7 @@ def test_get_agent_messages_paginated_envelope(self, client: DailyBotClient) -> ], } - with patch("httpx.get", return_value=mock_response): + with patch("dailybot_cli.api_client.httpx.request", return_value=mock_response): result: list[dict[str, Any]] = client.get_agent_messages(agent_name="Bot") assert len(result) == 1 @@ -878,7 +878,7 @@ def test_get_agent_messages_plain_list(self, client: DailyBotClient) -> None: {"id": "msg-1", "content": "hello"}, ] - with patch("httpx.get", return_value=mock_response): + with patch("dailybot_cli.api_client.httpx.request", return_value=mock_response): result: list[dict[str, Any]] = client.get_agent_messages(agent_name="Bot") assert len(result) == 1 @@ -894,7 +894,7 @@ def test_get_agent_messages_empty_envelope(self, client: DailyBotClient) -> None "results": [], } - with patch("httpx.get", return_value=mock_response): + with patch("dailybot_cli.api_client.httpx.request", return_value=mock_response): result: list[dict[str, Any]] = client.get_agent_messages(agent_name="Bot") assert result == [] @@ -911,14 +911,13 @@ def test_send_chat_message_passes_payload_through(self, client: DailyBotClient) "target_channels": ["C0123"], "platform_settings": {"bot_username": "Release Bot"}, } - with patch("httpx.post", return_value=mock_response) as mock_post: + with patch("dailybot_cli.api_client.httpx.request", return_value=mock_response) as mock_req: result: dict[str, Any] = client.send_chat_message(payload) - call_args = mock_post.call_args - assert call_args[0][0] == "http://test-api.example.com/v1/send-message/" - # Body is passed through verbatim — future fields need no client change. - assert call_args[1]["json"] == payload - assert call_args[1]["headers"]["X-API-KEY"] == "test-api-key" + call_kwargs: dict[str, Any] = mock_req.call_args[1] + assert mock_req.call_args[0][1] == "http://test-api.example.com/v1/send-message/" + assert call_kwargs["json"] == payload + assert call_kwargs["headers"]["Authorization"] == "Bearer test-token" assert result["bot_message_id"] == "123" def test_send_chat_message_returns_update_id(self, client: DailyBotClient) -> None: @@ -926,12 +925,12 @@ def test_send_chat_message_returns_update_id(self, client: DailyBotClient) -> No mock_response.status_code = 200 mock_response.json.return_value = {"bot_message_id": "task-uuid"} - with patch("httpx.post", return_value=mock_response) as mock_post: + with patch("dailybot_cli.api_client.httpx.request", return_value=mock_response) as mock_req: result: dict[str, Any] = client.send_chat_message( {"bot_message_id": "task-uuid", "message": "DONE", "target_channels": ["C0"]} ) - assert mock_post.call_args[1]["json"]["bot_message_id"] == "task-uuid" + assert mock_req.call_args[1]["json"]["bot_message_id"] == "task-uuid" assert result["bot_message_id"] == "task-uuid" @@ -962,14 +961,22 @@ def test_api_error_non_json(self, client: DailyBotClient) -> None: class TestAgentDualAuth: - def test_agent_headers_prefers_api_key(self) -> None: + def test_agent_headers_prefers_bearer_over_api_key(self) -> None: + """Unified priority: Bearer first, API key second — same as _headers().""" client = DailyBotClient(api_url="http://test.com", token="tok", api_key="key123") headers = client._agent_headers() + assert headers["Authorization"] == "Bearer tok" + assert "X-API-KEY" not in headers + assert client._agent_auth_mode == "bearer" + + def test_agent_headers_falls_back_to_api_key(self) -> None: + client = DailyBotClient(api_url="http://test.com", token=None, api_key="key123") + headers = client._agent_headers() assert headers["X-API-KEY"] == "key123" assert "Authorization" not in headers assert client._agent_auth_mode == "api_key" - def test_agent_headers_falls_back_to_bearer(self) -> None: + def test_agent_headers_bearer_only(self) -> None: client = DailyBotClient(api_url="http://test.com", token="tok", api_key=None) headers = client._agent_headers() assert headers["Authorization"] == "Bearer tok" @@ -1000,7 +1007,7 @@ def test_handle_response_401_bearer_message(self) -> None: assert "dailybot login" in exc_info.value.detail def test_handle_response_401_api_key_unchanged(self) -> None: - client = DailyBotClient(api_url="http://test.com", token="tok", api_key="key123") + client = DailyBotClient(api_url="http://test.com", token=None, api_key="key123") client._agent_headers() # sets _agent_auth_mode to "api_key" mock_response: MagicMock = MagicMock(spec=httpx.Response) @@ -1013,6 +1020,117 @@ def test_handle_response_401_api_key_unchanged(self) -> None: assert exc_info.value.detail == "Invalid API key" +class TestAgentAuthFallback: + """On 401, _agent_request retries with the alternative credential.""" + + def test_bearer_rejected_retries_with_api_key(self) -> None: + """Primary Bearer fails → retry with API key.""" + client = DailyBotClient( + api_url="http://test.com", token="expired-token", api_key="valid-key" + ) + rejected: MagicMock = MagicMock(spec=httpx.Response) + rejected.status_code = 401 + rejected.json.return_value = {"detail": "Unauthorized"} + + success: MagicMock = MagicMock(spec=httpx.Response) + success.status_code = 200 + success.json.return_value = {"id": 1, "uuid": "abc"} + + with patch( + "dailybot_cli.api_client.httpx.request", side_effect=[rejected, success] + ) as mock_req: + result: dict[str, Any] = client.submit_agent_report( + agent_name="Test Agent", content="Hello" + ) + + assert result == {"id": 1, "uuid": "abc"} + assert mock_req.call_count == 2 + first_headers: dict[str, str] = mock_req.call_args_list[0][1]["headers"] + assert first_headers.get("Authorization") == "Bearer expired-token" + retry_headers: dict[str, str] = mock_req.call_args_list[1][1]["headers"] + assert retry_headers.get("X-API-KEY") == "valid-key" + assert "Authorization" not in retry_headers + + def test_api_key_rejected_retries_with_bearer(self) -> None: + """API-key-only client (no Bearer) fails → no retry. But if Bearer + is added later (e.g. only api_key set at init), retry would work.""" + client = DailyBotClient(api_url="http://test.com", token=None, api_key="stale-key") + rejected: MagicMock = MagicMock(spec=httpx.Response) + rejected.status_code = 401 + rejected.json.return_value = {"detail": "API Key Not Valid"} + + with ( + patch("dailybot_cli.api_client.httpx.request", return_value=rejected) as mock_req, + pytest.raises(APIError) as exc_info, + ): + client.submit_agent_report(agent_name="Test", content="Hi") + + assert exc_info.value.status_code == 401 + assert mock_req.call_count == 1 + + def test_no_retry_on_non_401_errors(self) -> None: + client = DailyBotClient(api_url="http://test.com", token="valid-token", api_key="key") + forbidden: MagicMock = MagicMock(spec=httpx.Response) + forbidden.status_code = 403 + forbidden.json.return_value = {"detail": "Forbidden"} + + with ( + patch("dailybot_cli.api_client.httpx.request", return_value=forbidden) as mock_req, + pytest.raises(APIError) as exc_info, + ): + client.submit_agent_report(agent_name="Test", content="Hi") + + assert exc_info.value.status_code == 403 + assert mock_req.call_count == 1 + + def test_no_retry_when_only_one_credential(self) -> None: + """Bearer-only client: no alternative credential → no retry.""" + client = DailyBotClient(api_url="http://test.com", token="expired-token", api_key=None) + rejected: MagicMock = MagicMock(spec=httpx.Response) + rejected.status_code = 401 + rejected.json.return_value = {"detail": "Unauthorized"} + + with ( + patch("dailybot_cli.api_client.httpx.request", return_value=rejected) as mock_req, + pytest.raises(APIError) as exc_info, + ): + client.submit_agent_report(agent_name="Test", content="Hi") + + assert mock_req.call_count == 1 + assert "Session expired" in exc_info.value.detail + + def test_fallback_works_for_agent_health(self) -> None: + client = DailyBotClient( + api_url="http://test.com", token="expired-token", api_key="valid-key" + ) + rejected: MagicMock = MagicMock(spec=httpx.Response) + rejected.status_code = 401 + + success: MagicMock = MagicMock(spec=httpx.Response) + success.status_code = 200 + success.json.return_value = {"ok": True} + + with patch("dailybot_cli.api_client.httpx.request", side_effect=[rejected, success]): + result: dict[str, Any] = client.submit_agent_health(agent_name="Test", ok=True) + + assert result == {"ok": True} + + def test_both_credentials_available_bearer_goes_first(self) -> None: + """When both credentials exist, Bearer is tried first (unified priority).""" + client = DailyBotClient(api_url="http://test.com", token="good-token", api_key="good-key") + success: MagicMock = MagicMock(spec=httpx.Response) + success.status_code = 200 + success.json.return_value = {"id": 1} + + with patch("dailybot_cli.api_client.httpx.request", return_value=success) as mock_req: + client.submit_agent_report(agent_name="Test", content="Hi") + + assert mock_req.call_count == 1 + headers: dict[str, str] = mock_req.call_args[1]["headers"] + assert headers.get("Authorization") == "Bearer good-token" + assert "X-API-KEY" not in headers + + class TestHeadersDualAuth: def test_headers_sends_api_key_when_no_token(self) -> None: """_headers() sends X-API-KEY when no Bearer token is available."""