diff --git a/pyoverkiz/auth/strategies.py b/pyoverkiz/auth/strategies.py index db903098..aa1484bb 100644 --- a/pyoverkiz/auth/strategies.py +++ b/pyoverkiz/auth/strategies.py @@ -715,15 +715,13 @@ async def login(self) -> None: }, ssl=self._ssl, ) as response: + await _raise_for_server_error(response) if response.status >= HTTPStatus.BAD_REQUEST: message = "Login failed: bad credentials" - try: - body = await response.json() - errors = body.get("error") - if errors: - message = errors[0] - except ValueError: - pass + body = await _json_body(response) + errors = body.get("error") + if errors: + message = errors[0] raise BrandtBadCredentialsError(message) # 2) Fetch the JWT, authenticated purely by the session cookie. @@ -731,6 +729,7 @@ async def login(self) -> None: f"{BRANDT_MIDDLEWARE_API}/api/v1/profile/jwt.json", ssl=self._ssl, ) as response: + await _raise_for_server_error(response) if response.status >= HTTPStatus.BAD_REQUEST: raise BrandtServiceError( f"{self.server.name} JWT request failed: {response.status}" diff --git a/tests/test_auth.py b/tests/test_auth.py index c0ce8b45..1618e87d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -718,10 +718,34 @@ def _server_config(name): ) @staticmethod - def _json_response(status, payload): + def _json_response(status, payload, *, html: str | None = None): + """Return a mock middleware response. + + With `html`, the body is non-JSON and `json()` fails the way aiohttp + does: ContentTypeError by default, JSONDecodeError with + `content_type=None` (as `check_response` calls it). + """ + from json import JSONDecodeError + + from aiohttp import ContentTypeError + resp = MagicMock() resp.status = status - resp.json = AsyncMock(return_value=payload) + if html is None: + resp.json = AsyncMock(return_value=payload) + else: + + async def _json(content_type: str | None = "application/json"): + if content_type is None: + raise JSONDecodeError("Expecting value", html, 0) + raise ContentTypeError( + request_info=MagicMock(), + history=(), + message="Attempt to decode JSON with unexpected mimetype: text/html", + ) + + resp.json = AsyncMock(side_effect=_json) + resp.text = AsyncMock(return_value=html) resp.__aenter__ = AsyncMock(return_value=resp) resp.__aexit__ = AsyncMock(return_value=None) return resp @@ -800,6 +824,93 @@ async def test_login_bad_credentials(self, strategy_cls, server_name): with pytest.raises(BrandtBadCredentialsError, match="Password wrong password"): await strategy.login() + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("strategy_cls", "server_name"), + [ + (BrandtAuthStrategy, "Brandt Smart Control"), + (SauterAuthStrategy, "Sauter Smart Control"), + ], + ) + async def test_login_sessions_503_raises_service_unavailable( + self, strategy_cls, server_name + ): + """A middleware outage must not be reported as bad credentials.""" + from pyoverkiz.exceptions import ServiceUnavailableError + + session = AsyncMock(spec=ClientSession) + session.post = MagicMock( + return_value=self._json_response( + 503, None, html="503 Service Unavailable" + ) + ) + + credentials = UsernamePasswordCredentials("a@b.c", "pass") + strategy = strategy_cls( + credentials, session, self._server_config(server_name), True + ) + with pytest.raises(ServiceUnavailableError): + await strategy.login() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("strategy_cls", "server_name"), + [ + (BrandtAuthStrategy, "Brandt Smart Control"), + (SauterAuthStrategy, "Sauter Smart Control"), + ], + ) + async def test_login_non_json_401_raises_bad_credentials( + self, strategy_cls, server_name + ): + """A non-JSON 401 (e.g. proxy HTML page) still maps to bad credentials.""" + from pyoverkiz.exceptions import BrandtBadCredentialsError + + session = AsyncMock(spec=ClientSession) + session.post = MagicMock( + return_value=self._json_response( + 401, None, html="401 Unauthorized" + ) + ) + + credentials = UsernamePasswordCredentials("a@b.c", "wrong") + strategy = strategy_cls( + credentials, session, self._server_config(server_name), True + ) + with pytest.raises(BrandtBadCredentialsError, match="bad credentials"): + await strategy.login() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("strategy_cls", "server_name"), + [ + (BrandtAuthStrategy, "Brandt Smart Control"), + (SauterAuthStrategy, "Sauter Smart Control"), + ], + ) + async def test_login_jwt_503_raises_service_unavailable( + self, strategy_cls, server_name + ): + """A 503 from profile/jwt.json raises ServiceUnavailableError.""" + from pyoverkiz.exceptions import ServiceUnavailableError + + session = AsyncMock(spec=ClientSession) + session.post = MagicMock( + return_value=self._json_response(200, {"client": {"email": "a@b.c"}}) + ) + session.get = MagicMock( + return_value=self._json_response( + 503, None, html="503 Service Unavailable" + ) + ) + + credentials = UsernamePasswordCredentials("a@b.c", "pass") + strategy = strategy_cls( + credentials, session, self._server_config(server_name), True + ) + with pytest.raises(ServiceUnavailableError): + await strategy.login() + @pytest.mark.asyncio @pytest.mark.parametrize( ("strategy_cls", "server_name"),