From 69182664e8cad6239b2db93e02a26127f303aa37 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Mon, 13 Jul 2026 01:55:48 +0000 Subject: [PATCH 1/4] fix(client): auto-retry agent requests with Bearer when API key is rejected When a profile's API key is stale/revoked and the server returns 401, agent commands now automatically retry with the login session token instead of failing with "API Key Not Valid". This prevents users who have a valid login session from being blocked by an expired API key in their agents.json profile. The new _agent_request() wrapper centralizes all 11 agent endpoint calls through a single retry path: try X-API-KEY first, and on 401 fall back to Bearer if available. No retry occurs when already using Bearer or when the error is not 401. Co-authored-by: Cursor --- dailybot_cli/api_client.py | 123 +++++++++++++++++++---------------- tests/api_client_test.py | 128 +++++++++++++++++++++++++++++++------ 2 files changed, 176 insertions(+), 75 deletions(-) diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index ae63feb..cef812d 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -171,6 +171,51 @@ def _agent_headers(self) -> dict[str, str]: self._agent_auth_mode = None return headers + def _bearer_only_headers(self) -> dict[str, str]: + """Build headers using only the Bearer token (for API-key-rejected retry).""" + headers: dict[str, str] = { + "Content-Type": "application/json", + "Accept": "application/json", + } + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + self._agent_auth_mode = "bearer" + return headers + + def _can_retry_with_bearer(self) -> bool: + """True when the last agent request used an API key and a Bearer token + is available as a fallback.""" + return self._agent_auth_mode == "api_key" and bool(self.token) + + 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 Bearer fallback. + + Tries with ``_agent_headers()`` (API key preferred). If the server + returns 401 and a Bearer login token is available, retries once with the + Bearer token so that a stale/revoked API key does not block users who + have a valid login session. + """ + 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 and self._can_retry_with_bearer(): + kwargs["headers"] = self._bearer_only_headers() + 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 +1465,8 @@ def submit_agent_report( payload["is_milestone"] = True if co_authors: payload["co_authors"] = co_authors - response: httpx.Response = httpx.post( - f"{self.api_url}/v1/agent-reports/", - json=payload, - headers=self._agent_headers(), - timeout=self.timeout, + response: httpx.Response = self._agent_request( + "POST", f"{self.api_url}/v1/agent-reports/", json=payload, ) return self._handle_response(response) @@ -1441,21 +1483,15 @@ def submit_agent_health( } if message: payload["message"] = message - response: httpx.Response = httpx.post( - f"{self.api_url}/v1/agent-health/", - json=payload, - headers=self._agent_headers(), - timeout=self.timeout, + response: httpx.Response = self._agent_request( + "POST", f"{self.api_url}/v1/agent-health/", json=payload, ) 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( - f"{self.api_url}/v1/agent-health/", - params={"agent_name": agent_name}, - headers=self._agent_headers(), - timeout=self.timeout, + response: httpx.Response = self._agent_request( + "GET", f"{self.api_url}/v1/agent-health/", params={"agent_name": agent_name}, ) return self._handle_response(response) @@ -1474,22 +1510,15 @@ def register_agent_webhook( } if webhook_secret: payload["webhook_secret"] = webhook_secret - response: httpx.Response = httpx.post( - f"{self.api_url}/v1/agent-webhook/", - json=payload, - headers=self._agent_headers(), - timeout=self.timeout, + response: httpx.Response = self._agent_request( + "POST", f"{self.api_url}/v1/agent-webhook/", json=payload, ) 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( - "DELETE", - f"{self.api_url}/v1/agent-webhook/", - json={"agent_name": agent_name}, - headers=self._agent_headers(), - timeout=self.timeout, + response: httpx.Response = self._agent_request( + "DELETE", f"{self.api_url}/v1/agent-webhook/", json={"agent_name": agent_name}, ) return self._handle_response(response) @@ -1512,11 +1541,8 @@ def send_agent_email( } if metadata: payload["metadata"] = metadata - response: httpx.Response = httpx.post( - f"{self.api_url}/v1/agent-email/send/", - json=payload, - headers=self._agent_headers(), - timeout=self.timeout, + response: httpx.Response = self._agent_request( + "POST", f"{self.api_url}/v1/agent-email/send/", json=payload, ) return self._handle_response(response) @@ -1543,11 +1569,8 @@ 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( - f"{self.api_url}/v1/send-message/", - json=payload, - headers=self._agent_headers(), - timeout=self.timeout, + response: httpx.Response = self._agent_request( + "POST", f"{self.api_url}/v1/send-message/", json=payload, ) return self._handle_response(response) @@ -1563,11 +1586,8 @@ 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( - f"{self.api_url}/v1/open-conversation/", - json={"users_uuids": users_uuids}, - headers=self._agent_headers(), - timeout=self.timeout, + response: httpx.Response = self._agent_request( + "POST", f"{self.api_url}/v1/open-conversation/", json={"users_uuids": users_uuids}, ) return self._handle_response(response) @@ -1598,11 +1618,8 @@ def send_agent_message( payload["sender_type"] = sender_type if sender_name: payload["sender_name"] = sender_name - response: httpx.Response = httpx.post( - f"{self.api_url}/v1/agent-messages/", - json=payload, - headers=self._agent_headers(), - timeout=self.timeout, + response: httpx.Response = self._agent_request( + "POST", f"{self.api_url}/v1/agent-messages/", json=payload, ) return self._handle_response(response) @@ -1615,11 +1632,8 @@ 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( - f"{self.api_url}/v1/agent-messages/", - params=params, - headers=self._agent_headers(), - timeout=self.timeout, + response: httpx.Response = self._agent_request( + "GET", f"{self.api_url}/v1/agent-messages/", params=params, ) if response.status_code >= 400: self._handle_response(response) @@ -1635,11 +1649,8 @@ def mark_agent_messages_read( message_ids: list[str], ) -> dict[str, Any]: """PATCH /v1/agent-messages/read/""" - response: httpx.Response = httpx.patch( - f"{self.api_url}/v1/agent-messages/read/", - json={"message_ids": message_ids}, - headers=self._agent_headers(), - timeout=self.timeout, + response: httpx.Response = self._agent_request( + "PATCH", f"{self.api_url}/v1/agent-messages/read/", json={"message_ids": message_ids}, ) return self._handle_response(response) diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 92a9f59..7b21881 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -793,13 +793,13 @@ 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 result["id"] == 1 @@ -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"]["X-API-KEY"] == "test-api-key" 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" @@ -1013,6 +1012,97 @@ def test_handle_response_401_api_key_unchanged(self) -> None: assert exc_info.value.detail == "Invalid API key" +class TestAgentBearerFallback: + """When an API key is rejected (401), _agent_request retries with Bearer.""" + + def test_retries_with_bearer_on_401(self) -> None: + client = DailyBotClient( + api_url="http://test.com", token="valid-token", api_key="stale-key" + ) + rejected: MagicMock = MagicMock(spec=httpx.Response) + rejected.status_code = 401 + rejected.json.return_value = {"detail": "API Key Not Valid"} + + 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("X-API-KEY") == "stale-key" + retry_headers: dict[str, str] = mock_req.call_args_list[1][1]["headers"] + assert retry_headers.get("Authorization") == "Bearer valid-token" + assert "X-API-KEY" not in retry_headers + + def test_no_retry_when_no_bearer_available(self) -> None: + 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): + with pytest.raises(APIError) as exc_info: + client.submit_agent_report(agent_name="Test", content="Hi") + + assert exc_info.value.status_code == 401 + + 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: + with 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_already_using_bearer(self) -> None: + 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: + with 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="valid-token", api_key="bad-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} + + class TestHeadersDualAuth: def test_headers_sends_api_key_when_no_token(self) -> None: """_headers() sends X-API-KEY when no Bearer token is available.""" From b3e31ac64bfc327e15a81a5f8d2a47842e59400e Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Mon, 13 Jul 2026 02:05:07 +0000 Subject: [PATCH 2/4] =?UTF-8?q?refactor(client):=20unify=20auth=20priority?= =?UTF-8?q?=20=E2=80=94=20Bearer=20first=20everywhere?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All endpoints now use Bearer-first, API-key-second priority, matching the behavior of _headers(). The _agent_request() retry is now bidirectional: Bearer rejected → retry with API key, and API key rejected → retry with Bearer. This eliminates the confusing inconsistency where user commands preferred Bearer but agent commands preferred API key. Co-authored-by: Cursor --- dailybot_cli/api_client.py | 55 +++++++++++++++++++------------- tests/api_client_test.py | 64 ++++++++++++++++++++++++++++---------- 2 files changed, 80 insertions(+), 39 deletions(-) diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index cef812d..3cc6caa 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -156,36 +156,45 @@ 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 _bearer_only_headers(self) -> dict[str, str]: - """Build headers using only the Bearer token (for API-key-rejected retry).""" + 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.token: + 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 - - def _can_retry_with_bearer(self) -> bool: - """True when the last agent request used an API key and a Bearer token - is available as a fallback.""" - return self._agent_auth_mode == "api_key" and bool(self.token) + return headers + return None def _agent_request( self, @@ -195,12 +204,12 @@ def _agent_request( json: dict[str, Any] | None = None, params: dict[str, Any] | None = None, ) -> httpx.Response: - """Execute an agent-authenticated request with automatic Bearer fallback. + """Execute an agent-authenticated request with automatic alt-credential retry. - Tries with ``_agent_headers()`` (API key preferred). If the server - returns 401 and a Bearer login token is available, retries once with the - Bearer token so that a stale/revoked API key does not block users who - have a valid login session. + 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: @@ -210,9 +219,11 @@ def _agent_request( response: httpx.Response = httpx.request(method, url, **kwargs) - if response.status_code == 401 and self._can_retry_with_bearer(): - kwargs["headers"] = self._bearer_only_headers() - 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 diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 7b21881..655366d 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -801,7 +801,7 @@ def test_submit_agent_report(self, client: DailyBotClient) -> None: 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: @@ -917,7 +917,7 @@ def test_send_chat_message_passes_payload_through(self, client: DailyBotClient) 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"]["X-API-KEY"] == "test-api-key" + 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: @@ -961,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" @@ -999,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) @@ -1012,16 +1020,17 @@ def test_handle_response_401_api_key_unchanged(self) -> None: assert exc_info.value.detail == "Invalid API key" -class TestAgentBearerFallback: - """When an API key is rejected (401), _agent_request retries with Bearer.""" +class TestAgentAuthFallback: + """On 401, _agent_request retries with the alternative credential.""" - def test_retries_with_bearer_on_401(self) -> None: + 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="valid-token", api_key="stale-key" + 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": "API Key Not Valid"} + rejected.json.return_value = {"detail": "Unauthorized"} success: MagicMock = MagicMock(spec=httpx.Response) success.status_code = 200 @@ -1035,12 +1044,14 @@ def test_retries_with_bearer_on_401(self) -> None: 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("X-API-KEY") == "stale-key" + 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("Authorization") == "Bearer valid-token" - assert "X-API-KEY" not in retry_headers + assert retry_headers.get("X-API-KEY") == "valid-key" + assert "Authorization" not in retry_headers - def test_no_retry_when_no_bearer_available(self) -> None: + 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" ) @@ -1048,11 +1059,12 @@ def test_no_retry_when_no_bearer_available(self) -> None: rejected.status_code = 401 rejected.json.return_value = {"detail": "API Key Not Valid"} - with patch("dailybot_cli.api_client.httpx.request", return_value=rejected): + with patch("dailybot_cli.api_client.httpx.request", return_value=rejected) as mock_req: with 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( @@ -1069,7 +1081,8 @@ def test_no_retry_on_non_401_errors(self) -> None: assert exc_info.value.status_code == 403 assert mock_req.call_count == 1 - def test_no_retry_when_already_using_bearer(self) -> None: + 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 ) @@ -1086,7 +1099,7 @@ def test_no_retry_when_already_using_bearer(self) -> None: def test_fallback_works_for_agent_health(self) -> None: client = DailyBotClient( - api_url="http://test.com", token="valid-token", api_key="bad-key" + api_url="http://test.com", token="expired-token", api_key="valid-key" ) rejected: MagicMock = MagicMock(spec=httpx.Response) rejected.status_code = 401 @@ -1102,6 +1115,23 @@ def test_fallback_works_for_agent_health(self) -> None: 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: From 507256ea2c1de79992c2a85c8c4a4470e961bc0f Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Mon, 13 Jul 2026 02:43:39 +0000 Subject: [PATCH 3/4] style(tests): combine nested with statements (ruff SIM117) Co-authored-by: Cursor --- tests/api_client_test.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 655366d..16c2e6e 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -1059,9 +1059,11 @@ def test_api_key_rejected_retries_with_bearer(self) -> None: 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: - with pytest.raises(APIError) as exc_info: - client.submit_agent_report(agent_name="Test", content="Hi") + 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 @@ -1074,9 +1076,11 @@ def test_no_retry_on_non_401_errors(self) -> None: forbidden.status_code = 403 forbidden.json.return_value = {"detail": "Forbidden"} - with patch("dailybot_cli.api_client.httpx.request", return_value=forbidden) as mock_req: - with pytest.raises(APIError) as exc_info: - client.submit_agent_report(agent_name="Test", content="Hi") + 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 @@ -1090,9 +1094,11 @@ def test_no_retry_when_only_one_credential(self) -> None: rejected.status_code = 401 rejected.json.return_value = {"detail": "Unauthorized"} - with patch("dailybot_cli.api_client.httpx.request", return_value=rejected) as mock_req: - with pytest.raises(APIError) as exc_info: - client.submit_agent_report(agent_name="Test", content="Hi") + 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 From 2d97ca41cc044d1065ca920a7b42eedfa81827c2 Mon Sep 17 00:00:00 2001 From: Sergio Alexander Florez Galeano Date: Mon, 13 Jul 2026 02:52:56 +0000 Subject: [PATCH 4/4] style: apply ruff format Co-authored-by: Cursor --- dailybot_cli/api_client.py | 44 ++++++++++++++++++++++++++++---------- tests/api_client_test.py | 24 +++++++-------------- 2 files changed, 41 insertions(+), 27 deletions(-) diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index 3cc6caa..49f501b 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -1477,7 +1477,9 @@ def submit_agent_report( if co_authors: payload["co_authors"] = co_authors response: httpx.Response = self._agent_request( - "POST", f"{self.api_url}/v1/agent-reports/", json=payload, + "POST", + f"{self.api_url}/v1/agent-reports/", + json=payload, ) return self._handle_response(response) @@ -1495,14 +1497,18 @@ def submit_agent_health( if message: payload["message"] = message response: httpx.Response = self._agent_request( - "POST", f"{self.api_url}/v1/agent-health/", json=payload, + "POST", + f"{self.api_url}/v1/agent-health/", + json=payload, ) 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 = self._agent_request( - "GET", f"{self.api_url}/v1/agent-health/", params={"agent_name": agent_name}, + "GET", + f"{self.api_url}/v1/agent-health/", + params={"agent_name": agent_name}, ) return self._handle_response(response) @@ -1522,14 +1528,18 @@ def register_agent_webhook( if webhook_secret: payload["webhook_secret"] = webhook_secret response: httpx.Response = self._agent_request( - "POST", f"{self.api_url}/v1/agent-webhook/", json=payload, + "POST", + f"{self.api_url}/v1/agent-webhook/", + json=payload, ) return self._handle_response(response) def unregister_agent_webhook(self, agent_name: str) -> dict[str, Any]: """DELETE /v1/agent-webhook/""" response: httpx.Response = self._agent_request( - "DELETE", f"{self.api_url}/v1/agent-webhook/", json={"agent_name": agent_name}, + "DELETE", + f"{self.api_url}/v1/agent-webhook/", + json={"agent_name": agent_name}, ) return self._handle_response(response) @@ -1553,7 +1563,9 @@ def send_agent_email( if metadata: payload["metadata"] = metadata response: httpx.Response = self._agent_request( - "POST", f"{self.api_url}/v1/agent-email/send/", json=payload, + "POST", + f"{self.api_url}/v1/agent-email/send/", + json=payload, ) return self._handle_response(response) @@ -1581,7 +1593,9 @@ def send_chat_message(self, payload: dict[str, Any]) -> dict[str, Any]: of which can be fed back in a later call to edit the same message. """ response: httpx.Response = self._agent_request( - "POST", f"{self.api_url}/v1/send-message/", json=payload, + "POST", + f"{self.api_url}/v1/send-message/", + json=payload, ) return self._handle_response(response) @@ -1598,7 +1612,9 @@ def open_conversation(self, users_uuids: list[str]) -> dict[str, Any]: Bearer token). Returns ``{"channel": ""}``. """ response: httpx.Response = self._agent_request( - "POST", f"{self.api_url}/v1/open-conversation/", json={"users_uuids": users_uuids}, + "POST", + f"{self.api_url}/v1/open-conversation/", + json={"users_uuids": users_uuids}, ) return self._handle_response(response) @@ -1630,7 +1646,9 @@ def send_agent_message( if sender_name: payload["sender_name"] = sender_name response: httpx.Response = self._agent_request( - "POST", f"{self.api_url}/v1/agent-messages/", json=payload, + "POST", + f"{self.api_url}/v1/agent-messages/", + json=payload, ) return self._handle_response(response) @@ -1644,7 +1662,9 @@ def get_agent_messages( if delivered is not None: params["delivered"] = "true" if delivered else "false" response: httpx.Response = self._agent_request( - "GET", f"{self.api_url}/v1/agent-messages/", params=params, + "GET", + f"{self.api_url}/v1/agent-messages/", + params=params, ) if response.status_code >= 400: self._handle_response(response) @@ -1661,7 +1681,9 @@ def mark_agent_messages_read( ) -> dict[str, Any]: """PATCH /v1/agent-messages/read/""" response: httpx.Response = self._agent_request( - "PATCH", f"{self.api_url}/v1/agent-messages/read/", json={"message_ids": message_ids}, + "PATCH", + f"{self.api_url}/v1/agent-messages/read/", + json={"message_ids": message_ids}, ) return self._handle_response(response) diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 16c2e6e..af9b847 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -1036,7 +1036,9 @@ def test_bearer_rejected_retries_with_api_key(self) -> None: 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: + 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" ) @@ -1052,9 +1054,7 @@ def test_bearer_rejected_retries_with_api_key(self) -> None: 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" - ) + 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"} @@ -1069,9 +1069,7 @@ def test_api_key_rejected_retries_with_bearer(self) -> None: 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" - ) + 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"} @@ -1087,9 +1085,7 @@ def test_no_retry_on_non_401_errors(self) -> None: 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 - ) + 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"} @@ -1115,17 +1111,13 @@ def test_fallback_works_for_agent_health(self) -> None: 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 - ) + 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" - ) + 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}