diff --git a/README.md b/README.md index cc56152..3a7de0b 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,13 @@ Click this button to skip steps 1 and 2 below: [![Open your Home Assistant insta This integration previously used a cookie-fishing authentication workflow. As of PR #267, it has now been replaced by a standard OAuth workflow similar to the mobile app, using your username and password, with auto-refreshing token retrieval. You no longer need to enter your cookie's string! +### Session expiry (every ~90 days) + +Generac's sign-in sessions expire 90 days after you log in, no matter how often the integration uses them. You have two options: + +- **Remember password** (checkbox at sign-in, reauth and reconfigure): the password is stored in Home Assistant and the integration signs in again on its own when the session expires. This doesn't work for accounts that require a verification code (SMS / email / authenticator), because the code can't be entered unattended. +- **Without it**: a Repairs warning appears about 10 days before expiry. Use **Reconfigure** on the integration to sign in again before your devices stop updating. + > [!IMPORTANT] > > ## Fair Warning diff --git a/custom_components/generac/__init__.py b/custom_components/generac/__init__.py index f8fb3e7..e382f94 100644 --- a/custom_components/generac/__init__.py +++ b/custom_components/generac/__init__.py @@ -5,6 +5,7 @@ https://github.com/binarydev/ha-generac """ import logging +import time from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -16,12 +17,15 @@ from .auth import GeneracAuth from .auth import InvalidGrantError from .const import CONF_DPOP_PEM +from .const import CONF_PASSWORD from .const import CONF_REFRESH_TOKEN +from .const import CONF_TOKEN_ISSUED_AT from .const import CONF_USERNAME from .const import DOMAIN from .const import PLATFORMS from .const import STARTUP_MESSAGE from .coordinator import GeneracDataUpdateCoordinator +from .issues import async_delete_token_expiry_issue from .utils import async_client_session _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -56,6 +60,22 @@ async def _persist_rt(new_rt: str) -> None: auth.set_refresh_token_persist_callback(_persist_rt) + password = entry.data.get(CONF_PASSWORD) + if password: + + async def _persist_relogin(fresh: GeneracAuth) -> None: + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_REFRESH_TOKEN: fresh.refresh_token, + CONF_DPOP_PEM: fresh.pem_str, + CONF_TOKEN_ISSUED_AT: time.time(), + }, + ) + + auth.enable_auto_relogin(password, _persist_relogin) + client = GeneracApiClient(session, auth) coordinator = GeneracDataUpdateCoordinator(hass, client=client, config_entry=entry) try: @@ -94,3 +114,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Reload config entry.""" await hass.config_entries.async_reload(entry.entry_id) + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Clean up the expiry Repairs issue when the entry is deleted.""" + async_delete_token_expiry_issue(hass, entry) diff --git a/custom_components/generac/auth.py b/custom_components/generac/auth.py index 86bde14..f7393e7 100644 --- a/custom_components/generac/auth.py +++ b/custom_components/generac/auth.py @@ -5,16 +5,20 @@ * Email + password universal-login against `auth.ecobee.com` (Auth0 tenant shared with the ecobee mobile apps). * PKCE + DPoP-bound authorization code exchange. -* Refresh-token rotation off — the same RT is reusable indefinitely as - long as we keep proving possession of the original DPoP key. +* Refresh-token rotation off — the same RT is reused for every refresh + as long as we keep proving possession of the original DPoP key. The DPoP private key is therefore part of the credential and must be persisted alongside the refresh token. We expose the key as a PEM string so it can live in the ConfigEntry's normal `data` dict. Refresh tokens for this client are NOT rotated by Auth0 (verified -empirically with multiple successive refreshes). We never need to -rewrite the ConfigEntry on a successful refresh. +empirically with multiple successive refreshes), but they DO have an +absolute lifetime of ~90 days from login (observed in production: the +token endpoint returns `invalid_grant` 90 days after login even though +it was refreshed every 2 h). Refreshing does not extend it; only a new +login does. When the user opts in to storing their password, the RT +expiry is handled by a silent re-login (see `enable_auto_relogin`). """ from __future__ import annotations @@ -906,6 +910,22 @@ def __init__( self._dpop_nonce: Optional[str] = None self._refresh_lock = asyncio.Lock() self._rt_persist_cb: Optional[Callable[[str], Awaitable[None]]] = None + self._password: Optional[str] = None + self._relogin_cb: Optional[Callable[["GeneracAuth"], Awaitable[None]]] = None + + def enable_auto_relogin( + self, + password: str, + persist_cb: Callable[["GeneracAuth"], Awaitable[None]], + ) -> None: + """Re-run the full login when the refresh token is rejected. + + The callback receives this instance after it has adopted the fresh + refresh token + DPoP key, and must persist both (`refresh_token`, + `pem_str`) — the old pair is dead. + """ + self._password = password + self._relogin_cb = persist_cb def set_refresh_token_persist_callback( self, cb: Optional[Callable[[str], Awaitable[None]]] @@ -991,10 +1011,55 @@ async def ensure_access_token(self) -> str: and time.time() < self._access_token_exp - self._ACCESS_TOKEN_LEEWAY ): return self._access_token - await self._refresh() + try: + await self._refresh() + except InvalidGrantError: + if self._password is None or not self._email: + raise + _LOGGER.warning( + "Generac refresh token rejected; logging in again with " + "the stored password" + ) + await self._relogin() assert self._access_token is not None return self._access_token + async def _relogin(self) -> None: + """Mint a new RT + DPoP key via a full login and adopt them. + + Raises InvalidGrantError when the login can't complete unattended + (wrong password, MFA) so the caller still lands in HA's reauth flow. + Transport / unexpected errors propagate unchanged so the coordinator + retries on the next poll instead of prompting. + """ + assert self._password is not None and self._email + try: + fresh = await GeneracAuth.login(self._session, self._email, self._password) + except MfaRequiredError as ex: + await ex.flow.aclose() + raise InvalidGrantError( + "refresh token expired and re-login requires a one-time MFA code" + ) from ex + except (InvalidCredentialsError, MfaUnsupportedError) as ex: + raise InvalidGrantError( + f"refresh token expired and re-login failed: {ex}" + ) from ex + + self._key = fresh._key + self._refresh_token = fresh._refresh_token + self._access_token = fresh._access_token + self._access_token_exp = fresh._access_token_exp + self._dpop_nonce = None + _LOGGER.info("Generac re-login OK; new refresh token issued") + if self._relogin_cb is not None: + try: + await self._relogin_cb(self) + except Exception: # noqa: BLE001 + _LOGGER.exception( + "Re-login succeeded but persisting the new credentials " + "failed; the next HA restart will log in again" + ) + async def _refresh(self) -> None: body = { "grant_type": "refresh_token", diff --git a/custom_components/generac/config_flow.py b/custom_components/generac/config_flow.py index eda495e..6233b6b 100644 --- a/custom_components/generac/config_flow.py +++ b/custom_components/generac/config_flow.py @@ -22,6 +22,7 @@ """ import asyncio import logging +import time import aiohttp import voluptuous as vol @@ -40,7 +41,9 @@ from .const import CONF_OPTIONS from .const import CONF_PASSWORD from .const import CONF_REFRESH_TOKEN +from .const import CONF_REMEMBER_PASSWORD from .const import CONF_SCAN_INTERVAL +from .const import CONF_TOKEN_ISSUED_AT from .const import CONF_USERNAME from .const import DEFAULT_SCAN_INTERVAL from .const import DOMAIN @@ -91,12 +94,24 @@ def __init__(self): self._pending_scan_interval: int | None = None @staticmethod - def _entry_data(auth: GeneracAuth, email: str) -> dict: - return { + def _entry_data(auth: GeneracAuth, email: str, password: str | None) -> dict: + data = { CONF_USERNAME: email, CONF_REFRESH_TOKEN: auth.refresh_token, CONF_DPOP_PEM: auth.pem_str, + CONF_TOKEN_ISSUED_AT: time.time(), } + if password: + data[CONF_PASSWORD] = password + return data + + @staticmethod + def _merge_entry_data(entry: config_entries.ConfigEntry, entry_data: dict) -> dict: + """Overlay fresh credentials, dropping a stored password on opt-out.""" + data = {**entry.data, **entry_data} + if CONF_PASSWORD not in entry_data: + data.pop(CONF_PASSWORD, None) + return data def _reload_if_setup_failed(self, entry: config_entries.ConfigEntry) -> None: """Force a reload when the entry isn't currently loaded. @@ -118,7 +133,7 @@ def _reload_if_setup_failed(self, entry: config_entries.ConfigEntry) -> None: self.hass.config_entries.async_schedule_reload(entry.entry_id) async def _try_login( - self, email: str, password: str + self, email: str, password: str, remember: bool = False ) -> tuple[dict | None, str | None]: """Run the full login flow. Returns (entry_data, error_key). @@ -150,7 +165,7 @@ async def _try_login( _LOGGER.error("Unexpected error during login: %s", ex, exc_info=True) return None, "internal" - return self._entry_data(auth, email), None + return self._entry_data(auth, email, password if remember else None), None async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" @@ -159,9 +174,10 @@ async def async_step_user(self, user_input=None): if user_input is not None: email = user_input[CONF_USERNAME].strip() password = user_input[CONF_PASSWORD] + remember = user_input.get(CONF_REMEMBER_PASSWORD, False) try: - entry_data, error = await self._try_login(email, password) + entry_data, error = await self._try_login(email, password, remember) except MfaRequiredError as ex: self._login_flow = ex.flow self._mfa_origin = "user" @@ -178,6 +194,7 @@ async def async_step_user(self, user_input=None): { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_REMEMBER_PASSWORD, default=False): bool, } ), errors=errors, @@ -199,9 +216,10 @@ async def async_step_reconfigure(self, user_input=None): email = user_input[CONF_USERNAME].strip() password = user_input[CONF_PASSWORD] scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + remember = user_input.get(CONF_REMEMBER_PASSWORD, False) try: - entry_data, error = await self._try_login(email, password) + entry_data, error = await self._try_login(email, password, remember) except MfaRequiredError as ex: self._login_flow = ex.flow self._mfa_origin = "reconfigure" @@ -229,6 +247,10 @@ async def async_step_reconfigure(self, user_input=None): vol.Required( CONF_SCAN_INTERVAL, default=default_scan_interval ): int, + vol.Optional( + CONF_REMEMBER_PASSWORD, + default=bool(entry and entry.data.get(CONF_PASSWORD)), + ): bool, } ), errors=errors, @@ -260,7 +282,7 @@ async def _finish_reconfigure(self, entry_data: dict): # unload, surfacing as "failed to unload". self.hass.config_entries.async_update_entry( entry, - data={**entry.data, **entry_data}, + data=self._merge_entry_data(entry, entry_data), options=new_options, ) self._reload_if_setup_failed(entry) @@ -290,9 +312,10 @@ async def async_step_reauth_confirm(self, user_input=None): # integration. This prevents silently rebinding the entry # (and all its entities) to a different Generac account. email = default_email + remember = user_input.get(CONF_REMEMBER_PASSWORD, False) try: - entry_data, error = await self._try_login(email, password) + entry_data, error = await self._try_login(email, password, remember) except MfaRequiredError as ex: self._login_flow = ex.flow self._mfa_origin = "reauth" @@ -308,6 +331,10 @@ async def async_step_reauth_confirm(self, user_input=None): data_schema=vol.Schema( { vol.Required(CONF_PASSWORD): str, + vol.Optional( + CONF_REMEMBER_PASSWORD, + default=bool(entry.data.get(CONF_PASSWORD)), + ): bool, } ), description_placeholders={"username": default_email}, @@ -325,7 +352,7 @@ async def _finish_reauth(self, entry_data: dict): # reload here would race the listener-driven one in the loaded case and # surface as "failed to unload". self.hass.config_entries.async_update_entry( - entry, data={**entry.data, **entry_data} + entry, data=self._merge_entry_data(entry, entry_data) ) self._reload_if_setup_failed(entry) return self.async_abort(reason="reauth_successful") @@ -371,7 +398,10 @@ async def async_step_mfa(self, user_input=None): await self._discard_login_flow() return self.async_abort(reason="mfa_failed") else: - entry_data = self._entry_data(auth, self._pending_email) + # No password is stored for MFA accounts: an unattended + # re-login would stall on the same challenge. They get the + # expiry Repairs warning instead. + entry_data = self._entry_data(auth, self._pending_email, None) self._login_flow = None return await self._finish_origin(entry_data) diff --git a/custom_components/generac/const.py b/custom_components/generac/const.py index d76e2da..3a2073d 100644 --- a/custom_components/generac/const.py +++ b/custom_components/generac/const.py @@ -52,6 +52,19 @@ CONF_REFRESH_TOKEN = "refresh_token" CONF_DPOP_PEM = "dpop_pem" CONF_SCAN_INTERVAL = "scan_interval" +# Opt-in: keep the password in entry.data so an expired refresh token can be +# replaced by a silent re-login instead of a manual reauth. +CONF_REMEMBER_PASSWORD = "remember_password" +# Unix timestamp of the login that minted the current refresh token. +CONF_TOKEN_ISSUED_AT = "token_issued_at" + +# Generac's Auth0 tenant caps refresh tokens at an absolute lifetime of +# ~90 days (observed: invalid_grant 90 days after login despite refreshing +# every 2 h). Using the token does not extend it; only a fresh login does. +REFRESH_TOKEN_LIFETIME_DAYS = 90 +# Raise a Repairs warning this many days before the expected expiry when +# there is no stored password to re-login with. +REFRESH_TOKEN_WARN_DAYS = 10 # Options bool_opts = {} diff --git a/custom_components/generac/coordinator.py b/custom_components/generac/coordinator.py index f4949d3..4558127 100644 --- a/custom_components/generac/coordinator.py +++ b/custom_components/generac/coordinator.py @@ -14,6 +14,7 @@ from .const import CONF_SCAN_INTERVAL from .const import DEFAULT_SCAN_INTERVAL from .const import DOMAIN +from .issues import async_update_token_expiry_issue from .models import Item _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -48,6 +49,7 @@ async def _async_update_data(self): items = await self.api.async_get_data() self.is_online = items is not None _LOGGER.info("Generac poll OK: %d device(s)", len(items) if items else 0) + async_update_token_expiry_issue(self.hass, self._config_entry) return items except (InvalidCredentialsException, InvalidGrantError) as ex: # Refresh token / login no longer valid — trigger HA reauth flow. diff --git a/custom_components/generac/issues.py b/custom_components/generac/issues.py new file mode 100644 index 0000000..bad0f17 --- /dev/null +++ b/custom_components/generac/issues.py @@ -0,0 +1,62 @@ +"""Repairs issue warning that the refresh token is about to expire.""" +from __future__ import annotations + +import time +from datetime import datetime +from datetime import timezone + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from .const import CONF_PASSWORD +from .const import CONF_TOKEN_ISSUED_AT +from .const import CONF_USERNAME +from .const import DOMAIN +from .const import REFRESH_TOKEN_LIFETIME_DAYS +from .const import REFRESH_TOKEN_WARN_DAYS + +_DAY = 86400 + + +def _issue_id(entry: ConfigEntry) -> str: + return f"refresh_token_expiring_{entry.entry_id}" + + +def async_update_token_expiry_issue(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Create or clear the "sign in again soon" Repairs issue for an entry. + + Only relevant when no password is stored: with one, an expired token is + replaced by a silent re-login. Entries created before the issue time was + recorded are skipped — their age is unknown. + """ + issued_at = entry.data.get(CONF_TOKEN_ISSUED_AT) + if entry.data.get(CONF_PASSWORD) or not isinstance(issued_at, (int, float)): + ir.async_delete_issue(hass, DOMAIN, _issue_id(entry)) + return + + expires_at = issued_at + REFRESH_TOKEN_LIFETIME_DAYS * _DAY + if time.time() < expires_at - REFRESH_TOKEN_WARN_DAYS * _DAY: + ir.async_delete_issue(hass, DOMAIN, _issue_id(entry)) + return + + ir.async_create_issue( + hass, + DOMAIN, + _issue_id(entry), + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="refresh_token_expiring", + translation_placeholders={ + "username": entry.data.get(CONF_USERNAME) or entry.title, + "expires": datetime.fromtimestamp(expires_at, timezone.utc).strftime( + "%Y-%m-%d" + ), + }, + ) + + +def async_delete_token_expiry_issue(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove the issue (entry deleted).""" + ir.async_delete_issue(hass, DOMAIN, _issue_id(entry)) diff --git a/custom_components/generac/translations/en.json b/custom_components/generac/translations/en.json index 81ccdc0..4e006d7 100644 --- a/custom_components/generac/translations/en.json +++ b/custom_components/generac/translations/en.json @@ -6,7 +6,11 @@ "description": "Sign in with your Mobile Link account.", "data": { "username": "Email", - "password": "Password" + "password": "Password", + "remember_password": "Remember password" + }, + "data_description": { + "remember_password": "Generac sessions expire about every 90 days. If enabled, the password is stored in Home Assistant so the integration can sign in again on its own. Not used for accounts that require a verification code." } }, "reconfigure": { @@ -15,14 +19,22 @@ "data": { "username": "Email", "password": "Password", - "scan_interval": "Cloud polling interval (seconds)" + "scan_interval": "Cloud polling interval (seconds)", + "remember_password": "Remember password" + }, + "data_description": { + "remember_password": "Generac sessions expire about every 90 days. If enabled, the password is stored in Home Assistant so the integration can sign in again on its own. Not used for accounts that require a verification code." } }, "reauth_confirm": { "title": "Reauthenticate Generac MobileLink", "description": "The session for {username} has expired. Enter your password to refresh it.", "data": { - "password": "Password" + "password": "Password", + "remember_password": "Remember password" + }, + "data_description": { + "remember_password": "Generac sessions expire about every 90 days. If enabled, the password is stored in Home Assistant so the integration can sign in again on its own. Not used for accounts that require a verification code." } }, "mfa": { @@ -68,5 +80,11 @@ } } } + }, + "issues": { + "refresh_token_expiring": { + "title": "Generac sign-in for {username} expires soon", + "description": "Generac sessions expire 90 days after sign-in. The session for {username} expires around {expires}, after which your Generac devices will stop updating.\n\nTo renew it, go to Settings → Devices & services → Generac MobileLink, open the ⋮ menu, choose **Reconfigure** and sign in again. Tick **Remember password** to have the integration renew the session automatically from now on." + } } } diff --git a/tests/test_auth.py b/tests/test_auth.py index 7aeec3a..dd16619 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -14,6 +14,7 @@ from custom_components.generac.auth import IDENTIFIER_URL from custom_components.generac.auth import InvalidCredentialsError from custom_components.generac.auth import InvalidGrantError +from custom_components.generac.auth import MfaRequiredError from custom_components.generac.auth import PASSWORD_URL @@ -338,3 +339,81 @@ async def test_identifier_and_password_steps_strip_email_whitespace(): assert "username=user%40example.com" in pw_body # Password whitespace is preserved verbatim assert "password=+pass+" in pw_body + + +# --------------------------------------------------------------------------- +# Silent re-login when the ~90-day refresh token expires +# --------------------------------------------------------------------------- + + +def _fresh_login_auth() -> GeneracAuth: + fresh = _make_auth(refresh_token="rt-FRESH") + fresh._access_token = "AT-FRESH" + fresh._access_token_exp = 9e12 + return fresh + + +@pytest.mark.asyncio +async def test_expired_rt_relogs_in_with_stored_password(monkeypatch): + """invalid_grant + stored password -> full login, adopt + persist new creds.""" + auth = _make_auth(refresh_token="rt-EXPIRED") + auth._refresh = AsyncMock(side_effect=InvalidGrantError("expired")) + fresh = _fresh_login_auth() + login = AsyncMock(return_value=fresh) + monkeypatch.setattr(GeneracAuth, "login", login) + persist = AsyncMock() + auth.enable_auto_relogin("hunter2", persist) + + assert await auth.ensure_access_token() == "AT-FRESH" + + login.assert_awaited_once_with(auth._session, "user@example.com", "hunter2") + assert auth.refresh_token == "rt-FRESH" + assert auth.pem_str == fresh.pem_str + persist.assert_awaited_once_with(auth) + + +@pytest.mark.asyncio +async def test_expired_rt_without_password_still_raises(monkeypatch): + """No stored password -> InvalidGrantError propagates (manual reauth).""" + auth = _make_auth() + auth._refresh = AsyncMock(side_effect=InvalidGrantError("expired")) + login = AsyncMock() + monkeypatch.setattr(GeneracAuth, "login", login) + + with pytest.raises(InvalidGrantError): + await auth.ensure_access_token() + login.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_relogin_needing_mfa_falls_back_to_reauth(monkeypatch): + """MFA can't be answered unattended: close the paused flow, raise for reauth.""" + auth = _make_auth() + auth._refresh = AsyncMock(side_effect=InvalidGrantError("expired")) + paused = MagicMock() + paused.aclose = AsyncMock() + monkeypatch.setattr( + GeneracAuth, + "login", + AsyncMock(side_effect=MfaRequiredError(paused, "sms", "url", "state")), + ) + persist = AsyncMock() + auth.enable_auto_relogin("hunter2", persist) + + with pytest.raises(InvalidGrantError): + await auth.ensure_access_token() + paused.aclose.assert_awaited_once() + persist.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_relogin_with_changed_password_falls_back_to_reauth(monkeypatch): + auth = _make_auth() + auth._refresh = AsyncMock(side_effect=InvalidGrantError("expired")) + monkeypatch.setattr( + GeneracAuth, "login", AsyncMock(side_effect=InvalidCredentialsError("bad")) + ) + auth.enable_auto_relogin("old-password", AsyncMock()) + + with pytest.raises(InvalidGrantError): + await auth.ensure_access_token() diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index e343028..9771b0b 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -9,6 +9,8 @@ from custom_components.generac.const import CONF_DPOP_PEM from custom_components.generac.const import CONF_PASSWORD from custom_components.generac.const import CONF_REFRESH_TOKEN +from custom_components.generac.const import CONF_REMEMBER_PASSWORD +from custom_components.generac.const import CONF_TOKEN_ISSUED_AT from custom_components.generac.const import CONF_USERNAME from custom_components.generac.const import DOMAIN from homeassistant import config_entries @@ -487,3 +489,61 @@ async def test_reconfigure_does_not_double_reload_when_loaded( await hass.async_block_till_done() mock_reload.assert_not_called() + + +async def test_form_user_remember_password(hass: HomeAssistant) -> None: + """Opting in stores the password and stamps the token issue time.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "custom_components.generac.config_flow.GeneracAuth.login", + AsyncMock(return_value=_mock_auth()), + ), patch("custom_components.generac.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "user@example.com", + CONF_PASSWORD: "hunter2", + CONF_REMEMBER_PASSWORD: True, + }, + ) + await hass.async_block_till_done() + + assert result2["data"][CONF_PASSWORD] == "hunter2" + assert isinstance(result2["data"][CONF_TOKEN_ISSUED_AT], float) + + +async def test_reauth_without_remember_drops_stored_password( + hass: HomeAssistant, +) -> None: + """Unticking "remember password" on reauth removes the stored password.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="user@example.com", + data={ + CONF_USERNAME: "user@example.com", + CONF_PASSWORD: "old-pw", + CONF_REFRESH_TOKEN: "stale-rt", + CONF_DPOP_PEM: DPoPKey.generate().to_pem_str(), + }, + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "reauth", "entry_id": entry.entry_id}, + data=entry.data, + ) + with patch( + "custom_components.generac.config_flow.GeneracAuth.login", + AsyncMock(return_value=_mock_auth(refresh_token="fresh-rt")), + ), patch("custom_components.generac.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-pw", CONF_REMEMBER_PASSWORD: False}, + ) + await hass.async_block_till_done() + + assert result2["reason"] == "reauth_successful" + assert entry.data[CONF_REFRESH_TOKEN] == "fresh-rt" + assert CONF_PASSWORD not in entry.data diff --git a/tests/test_issues.py b/tests/test_issues.py new file mode 100644 index 0000000..2514494 --- /dev/null +++ b/tests/test_issues.py @@ -0,0 +1,57 @@ +"""Tests for the refresh-token expiry Repairs issue.""" +import time + +from custom_components.generac.const import CONF_PASSWORD +from custom_components.generac.const import CONF_TOKEN_ISSUED_AT +from custom_components.generac.const import CONF_USERNAME +from custom_components.generac.const import DOMAIN +from custom_components.generac.issues import async_update_token_expiry_issue +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from pytest_homeassistant_custom_component.common import MockConfigEntry + +_DAY = 86400 + + +def _entry(hass, **data) -> MockConfigEntry: + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_USERNAME: "user@example.com", **data} + ) + entry.add_to_hass(hass) + return entry + + +def _issue(hass, entry): + return ir.async_get(hass).async_get_issue( + DOMAIN, f"refresh_token_expiring_{entry.entry_id}" + ) + + +async def test_issue_raised_near_expiry_and_cleared_after_relogin( + hass: HomeAssistant, +) -> None: + entry = _entry(hass, **{CONF_TOKEN_ISSUED_AT: time.time() - 85 * _DAY}) + async_update_token_expiry_issue(hass, entry) + issue = _issue(hass, entry) + assert issue is not None + assert issue.translation_key == "refresh_token_expiring" + + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_TOKEN_ISSUED_AT: time.time()} + ) + async_update_token_expiry_issue(hass, entry) + assert _issue(hass, entry) is None + + +async def test_no_issue_when_young_password_stored_or_age_unknown( + hass: HomeAssistant, +) -> None: + young = _entry(hass, **{CONF_TOKEN_ISSUED_AT: time.time() - 30 * _DAY}) + with_pw = _entry( + hass, + **{CONF_TOKEN_ISSUED_AT: time.time() - 85 * _DAY, CONF_PASSWORD: "pw"}, + ) + legacy = _entry(hass) + for entry in (young, with_pw, legacy): + async_update_token_expiry_issue(hass, entry) + assert _issue(hass, entry) is None