Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions pyoverkiz/auth/strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -715,22 +715,21 @@ 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.
async with self.session.get(
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}"
Expand Down
115 changes: 113 additions & 2 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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="<html><title>503 Service Unavailable</title></html>"
)
)

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="<html><title>401 Unauthorized</title></html>"
)
)

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="<html><title>503 Service Unavailable</title></html>"
)
)

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"),
Expand Down
Loading