diff --git a/.secrets.baseline b/.secrets.baseline index 83a918e..8fa770f 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -289,7 +289,7 @@ "filename": "src/api/hive_auth_async.py", "hashed_secret": "576956b5291ac38d04ef5f82cc974286a857f0b2", "is_verified": false, - "line_number": 113 + "line_number": 128 } ], "src/api/srp_crypto.py": [ @@ -580,5 +580,5 @@ } ] }, - "generated_at": "2026-09-26T17:01:30Z" + "generated_at": "2026-10-20T19:58:38Z" } diff --git a/src/api/hive_auth.py b/src/api/hive_auth.py index 793e3d8..50d37d6 100644 --- a/src/api/hive_auth.py +++ b/src/api/hive_auth.py @@ -126,6 +126,21 @@ def __init__( # pylint: disable=too-many-positional-arguments # noqa: PLR0913 aws_session_token="SESSION_TOKEN", ) + @property + def username(self) -> str: + """Return the Hive username, normalised for Cognito.""" + return self._username + + @username.setter + def username(self, value: str) -> None: + """Store the username trimmed and lower-cased. + + Hive's Cognito pool treats the email as case-sensitive, so a + capitalised address (e.g. from mobile autocapitalisation) fails + with an invalid-password error even though the account exists. + """ + self._username = value.strip().lower() if isinstance(value, str) else value + def generate_random_small_a(self): """Helper function to generate a random big integer. diff --git a/src/api/hive_auth_async.py b/src/api/hive_auth_async.py index 378e7a9..66a215d 100644 --- a/src/api/hive_auth_async.py +++ b/src/api/hive_auth_async.py @@ -88,6 +88,21 @@ def __init__( # pylint: disable=too-many-positional-arguments # noqa: PLR0913 self._region: str | None = None self.client: Any = None + @property + def username(self) -> str: + """Return the Hive username, normalised for Cognito.""" + return self._username + + @username.setter + def username(self, value: str) -> None: + """Store the username trimmed and lower-cased. + + Hive's Cognito pool treats the email as case-sensitive, so a + capitalised address (e.g. from mobile autocapitalisation) fails + with an invalid-password error even though the account exists. + """ + self._username = value.strip().lower() if isinstance(value, str) else value + async def async_init(self): """Initialise async variables.""" self.loop = asyncio.get_running_loop() diff --git a/tests/unit/test_hive_auth.py b/tests/unit/test_hive_auth.py index 746b9c0..ebc1522 100644 --- a/tests/unit/test_hive_auth.py +++ b/tests/unit/test_hive_auth.py @@ -1279,3 +1279,16 @@ def test_without_client_secret_no_secret_hash(self): response = auth.process_device_challenge(params) assert "SECRET_HASH" not in response + + +class TestUsernameNormalisation: + """Hive's Cognito pool is case-sensitive; usernames are lower-cased.""" + + def test_constructor_normalises_username(self): + auth = _make_auth(username=" User@Example.COM ") + assert auth.username == "user@example.com" + + def test_reassignment_is_normalised(self): + auth = _make_auth() + auth.username = "Someone@Example.com" + assert auth.username == "someone@example.com" diff --git a/tests/unit/test_hive_auth_async.py b/tests/unit/test_hive_auth_async.py index 5a0114b..6caab7f 100644 --- a/tests/unit/test_hive_auth_async.py +++ b/tests/unit/test_hive_auth_async.py @@ -1428,3 +1428,30 @@ async def test_async_init_raises_when_login_info_is_none(self): with patch("asyncio.get_running_loop", return_value=mock_loop): with pytest.raises(HiveUnknownConfiguration): await auth.async_init() + + +class TestUsernameNormalisation: + """Hive's Cognito pool is case-sensitive; usernames are lower-cased.""" + + @pytest.mark.parametrize( + "typed", + ["User@Example.com", "USER@EXAMPLE.COM", " user@example.com "], + ) + async def test_constructor_normalises_username(self, typed): + auth = await _make_auth(username=typed) + assert auth.username == "user@example.com" + + async def test_reassignment_is_normalised(self): + """start_session reassigns auth.username from the HA config entry.""" + auth = await _make_auth() + auth.username = "Someone@Example.com" + assert auth.username == "someone@example.com" + + async def test_initiate_auth_sends_lowercase_username(self): + auth = await _make_auth(username="User@Example.com") + params = await auth.get_auth_params() + assert params["USERNAME"] == "user@example.com" + + async def test_file_mode_detected_with_any_case(self): + auth = await _make_auth(username="Use@File.com") + assert auth.use_file is True