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
44 changes: 36 additions & 8 deletions custom_components/generac/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
149 changes: 149 additions & 0 deletions tests/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading