diff --git a/custom_components/generac/config_flow.py b/custom_components/generac/config_flow.py index 1864c28..fb7fd3a 100644 --- a/custom_components/generac/config_flow.py +++ b/custom_components/generac/config_flow.py @@ -26,6 +26,7 @@ import aiohttp import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import callback from .auth import GeneracAuth @@ -97,6 +98,25 @@ def _entry_data(auth: GeneracAuth, email: str) -> dict: CONF_DPOP_PEM: auth.pem_str, } + def _reload_if_setup_failed(self, entry: config_entries.ConfigEntry) -> None: + """Force a reload when the entry isn't currently loaded. + + The reload after a credential update normally rides on the update + listener registered in async_setup_entry. But that listener is only + registered once setup *succeeds*. When the initial setup failed — the + exact situation reauth/reconfigure exists to recover from (e.g. a + v1->v2 upgrade whose entry.data has no refresh token / DPoP key, which + raises ConfigEntryAuthFailed before the listener is wired up) — no + listener exists, so writing fresh credentials would never take effect + until a full HA restart. In that case reload explicitly. + + When the entry IS loaded the listener is present and will reload on + the data change, so we must NOT schedule a second reload here or the + two would race and surface as "failed to unload". + """ + if entry.state is not ConfigEntryState.LOADED: + self.hass.config_entries.async_schedule_reload(entry.entry_id) + async def _try_login( self, email: str, password: str ) -> tuple[dict | None, str | None]: @@ -230,16 +250,20 @@ async def _finish_reconfigure(self, entry_data: dict): **(entry.options or {}), CONF_SCAN_INTERVAL: int(self._pending_scan_interval), } - # Update only — the update listener registered in async_setup_entry - # will reload the entry exactly once. Calling - # async_update_reload_and_abort here would double-reload (helper - # schedules + listener fires) and race the unload, surfacing as - # "failed to unload". + # Update the stored data/options. When the entry is already loaded the + # update listener registered in async_setup_entry reloads it exactly + # once on this change. When the initial setup failed there is no such + # listener, so reload explicitly — otherwise the freshly-stored + # credentials sit unused until a full HA restart (issue #283). Calling + # async_update_reload_and_abort unconditionally would double-reload in + # the loaded case (helper schedules + listener fires) and race the + # unload, surfacing as "failed to unload". self.hass.config_entries.async_update_entry( entry, data={**entry.data, **entry_data}, options=new_options, ) + self._reload_if_setup_failed(entry) return self.async_abort(reason="reconfigure_successful") async def async_step_reauth(self, entry_data): @@ -294,12 +318,16 @@ async def _finish_reauth(self, entry_data: dict): """Terminal action for a reauth: overwrite creds in place.""" entry = self._reauth_entry assert entry is not None - # Update only — the update listener registered in async_setup_entry - # handles the reload. An explicit async_reload here would race the - # listener-driven reload and surface as "failed to unload". + # Update the stored credentials. When the entry is already loaded the + # update listener reloads it on this change; when the initial setup + # failed (no listener) reload explicitly so the new tokens take effect + # without a full HA restart (issue #283). An unconditional explicit + # 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} ) + self._reload_if_setup_failed(entry) return self.async_abort(reason="reauth_successful") async def async_step_mfa(self, user_input=None): diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 8f11fdb..e343028 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -338,3 +338,152 @@ async def test_reconfigure_flow_persists_scan_interval(hass: HomeAssistant) -> N await hass.async_block_till_done() assert entry.options.get(CONF_SCAN_INTERVAL) == 600 + + +async def test_reconfigure_reloads_when_setup_failed(hass: HomeAssistant) -> None: + """Regression (issue #283): reconfiguring an entry whose initial setup + failed must explicitly reload it. + + A v1->v2 upgrade leaves entry.data without a refresh token / DPoP key, so + async_setup_entry raises ConfigEntryAuthFailed before the update listener + is registered. Reconfigure then writes valid credentials, but with no + listener nothing reloads the entry — the fix schedules a reload so the new + tokens take effect without a full HA restart. + """ + from custom_components.generac.const import CONF_SCAN_INTERVAL + from homeassistant.config_entries import ConfigEntryState + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: "user@example.com"}, # v1-style: no RT / PEM + options={}, + entry_id="test", + ) + entry.add_to_hass(hass) + + # Initial setup fails (missing creds) -> no update listener, not LOADED. + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is not ConfigEntryState.LOADED + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "reconfigure", "entry_id": entry.entry_id}, + ) + assert result["step_id"] == "reconfigure" + + new_auth = _mock_auth(refresh_token="new-rt") + with patch( + "custom_components.generac.config_flow.GeneracAuth.login", + AsyncMock(return_value=new_auth), + ), patch( + "homeassistant.config_entries.ConfigEntries.async_schedule_reload" + ) as mock_reload: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "user@example.com", + CONF_PASSWORD: "new-pw", + CONF_SCAN_INTERVAL: 60, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert entry.data[CONF_REFRESH_TOKEN] == "new-rt" + assert CONF_DPOP_PEM in entry.data + mock_reload.assert_called_once_with(entry.entry_id) + + +async def test_reauth_reloads_when_setup_failed(hass: HomeAssistant) -> None: + """Regression (issue #283): reauth after a failed setup must reload too.""" + from homeassistant.config_entries import ConfigEntryState + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="user@example.com", + data={CONF_USERNAME: "user@example.com"}, # v1-style: no RT / PEM + entry_id="test", + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is not ConfigEntryState.LOADED + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "reauth", "entry_id": entry.entry_id}, + data=entry.data, + ) + assert result["step_id"] == "reauth_confirm" + + new_auth = _mock_auth(refresh_token="fresh-rt") + with patch( + "custom_components.generac.config_flow.GeneracAuth.login", + AsyncMock(return_value=new_auth), + ), patch( + "homeassistant.config_entries.ConfigEntries.async_schedule_reload" + ) as mock_reload: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-pw"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" + assert entry.data[CONF_REFRESH_TOKEN] == "fresh-rt" + mock_reload.assert_called_once_with(entry.entry_id) + + +async def test_reconfigure_does_not_double_reload_when_loaded( + hass: HomeAssistant, +) -> None: + """When the entry is already loaded, reconfigure must NOT schedule an + extra reload — the update listener handles it and a second reload would + race the unload ("failed to unload").""" + from custom_components.generac.const import CONF_SCAN_INTERVAL + + pem = DPoPKey.generate().to_pem_str() + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "user@example.com", + CONF_REFRESH_TOKEN: "old-rt", + CONF_DPOP_PEM: pem, + }, + options={}, + ) + entry.add_to_hass(hass) + + with patch("custom_components.generac.async_setup_entry", return_value=True): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "reconfigure", "entry_id": entry.entry_id}, + ) + assert result["step_id"] == "reconfigure" + + new_auth = _mock_auth(refresh_token="new-rt") + with patch( + "custom_components.generac.config_flow.GeneracAuth.login", + AsyncMock(return_value=new_auth), + ), patch("custom_components.generac.async_setup_entry", return_value=True), patch( + "custom_components.generac.async_unload_entry", return_value=True + ), patch( + "homeassistant.config_entries.ConfigEntries.async_schedule_reload" + ) as mock_reload: + await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "user@example.com", + CONF_PASSWORD: "new-pw", + CONF_SCAN_INTERVAL: 60, + }, + ) + await hass.async_block_till_done() + + mock_reload.assert_not_called()