Harden Z-Wave JS add-on config against concurrent and abandoned flows (#179816)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Balloob Bot
2026-08-23 18:16:53 +02:00
committed by GitHub
co-authored by Paulus Schoutsen Claude Fable 5
parent 1fb1057c73
commit 5b8bee1419
2 changed files with 428 additions and 17 deletions
@@ -1929,6 +1929,56 @@ async def test_esphome_discovery_without_home_id_can_be_ignored(
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("supervisor", "addon_running", "backup_nvm")
async def test_esphome_discovery_competing_migration_prompts(
hass: HomeAssistant,
integration: MockConfigEntry,
client: MagicMock,
) -> None:
"""Test starting a migration supersedes competing prompts."""
entry = integration
hass.config_entries.async_update_entry(
entry, unique_id="4321", data={**entry.data, "use_addon": True}
)
result_a = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ESPHOME},
data=ESPHOME_DISCOVERY_INFO,
)
result_b = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ESPHOME},
data=ESPHOME_DISCOVERY_INFO_CLEAN,
)
assert result_a["step_id"] == "confirm_usb_migration"
assert result_b["step_id"] == "confirm_usb_migration"
# Confirming the first migration removes the competing prompt.
with patch("pathlib.Path.write_bytes"):
result_a = await hass.config_entries.flow.async_configure(
result_a["flow_id"], {}
)
await hass.async_block_till_done()
assert result_a["type"] is FlowResultType.SHOW_PROGRESS
assert result_a["step_id"] == "backup_nvm"
assert not any(
flow["flow_id"] == result_b["flow_id"]
for flow in hass.config_entries.flow.async_progress()
)
# A migration confirmed while another is in flight is rejected.
result = await entry.start_reconfigure_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_in_progress"
@pytest.mark.usefixtures("supervisor", "addon_running", "addon_info")
async def test_esphome_discovery_already_configured_unmanaged_addon(
hass: HomeAssistant,
@@ -2607,6 +2657,57 @@ async def test_usb_discovery_ignored(
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("supervisor", "addon_running", "restart_addon")
async def test_concurrent_usb_setup_flows(
hass: HomeAssistant,
set_addon_options: AsyncMock,
mock_usb_serial_by_id: MagicMock,
) -> None:
"""Test a second setup flow can't change the add-on config concurrently."""
second_stick = UsbServiceInfo(
device="/dev/zwave2",
pid="BBBB",
vid="BBBB",
serial_number="5678",
description="zwave radio",
manufacturer="test",
)
result_a = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USB},
data=USB_DISCOVERY_INFO,
)
result_b = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USB},
data=second_stick,
)
assert result_a["step_id"] == "installation_type"
assert result_b["step_id"] == "installation_type"
result_a = await hass.config_entries.flow.async_configure(
result_a["flow_id"], {"next_step_id": "intent_recommended"}
)
assert result_a["type"] is FlowResultType.SHOW_PROGRESS
assert result_a["step_id"] == "start_addon"
set_addon_options.reset_mock()
# The second flow may not change the add-on config while the first
# flow is applying its own.
result_b = await hass.config_entries.flow.async_configure(
result_b["flow_id"], {"next_step_id": "intent_recommended"}
)
assert result_b["type"] is FlowResultType.ABORT
assert result_b["reason"] == "already_in_progress"
set_addon_options.assert_not_called()
hass.config_entries.flow.async_abort(result_a["flow_id"])
await hass.async_block_till_done()
@pytest.mark.usefixtures("supervisor", "addon_info")
async def test_abort_usb_discovery_addon_required(hass: HomeAssistant) -> None:
"""Test usb discovery aborted when existing entry not using add-on."""
@@ -2824,6 +2925,47 @@ async def test_reconfigure_addon_already_configured(
set_addon_options.assert_not_called()
@pytest.mark.usefixtures("supervisor", "addon_installed")
async def test_addon_already_configured_at_addon_start(
hass: HomeAssistant,
set_addon_options: AsyncMock,
) -> None:
"""Test the add-on start aborts when an add-on entry appeared late."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "intent_recommended"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon_user"
# An add-on based entry is configured while the flow shows the form,
# e.g. by a concurrent discovery flow.
entry = MockConfigEntry(
domain=DOMAIN,
data={
"url": "ws://localhost:3000",
"usb_path": "/other",
"use_addon": True,
},
title=TITLE,
unique_id="4321",
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"usb_path": "/test"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "addon_already_configured"
# The other entry's add-on config is untouched.
set_addon_options.assert_not_called()
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
@pytest.mark.usefixtures("supervisor", "addon_running")
async def test_addon_running(
hass: HomeAssistant,
@@ -6003,6 +6145,177 @@ async def test_create_entry_spares_migration_flow(
assert not hass.config_entries.flow.async_progress()
@pytest.mark.usefixtures("supervisor", "addon_running", "restart_addon")
async def test_reconfigure_abandoned_restores_addon_config(
hass: HomeAssistant,
integration: MockConfigEntry,
addon_options: dict[str, Any],
set_addon_options: AsyncMock,
) -> None:
"""Test an abandoned flow restores the add-on config it changed."""
addon_options.update(
{"device": "/test", "network_key": "legacy", "s0_legacy_key": "old123"}
)
entry = integration
hass.config_entries.async_update_entry(
entry, unique_id="1234", data={**entry.data, "use_addon": True}
)
result = await entry.start_reconfigure_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"use_addon": True}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon_reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"usb_path": "/new",
"s0_legacy_key": "old123",
},
)
assert set_addon_options.call_count == 1
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "start_addon"
assert entry.state is config_entries.ConfigEntryState.NOT_LOADED
# The user closes the dialog instead of waiting for the restart.
with patch(
"homeassistant.components.zwave_js.async_setup_entry", return_value=True
):
hass.config_entries.flow.async_abort(result["flow_id"])
await hass.async_block_till_done()
# The add-on config the flow changed is restored, without the legacy
# network key, before the reload recovers the entry.
assert set_addon_options.call_args == call(
"core_zwave_js",
AddonsOptions(config={"device": "/test", "s0_legacy_key": "old123"}),
)
assert entry.state is config_entries.ConfigEntryState.LOADED
@pytest.mark.usefixtures("supervisor", "addon_running", "restart_addon")
async def test_reconfigure_abandoned_restore_failure_keeps_unloaded(
hass: HomeAssistant,
integration: MockConfigEntry,
addon_options: dict[str, Any],
set_addon_options: AsyncMock,
) -> None:
"""Test the entry stays unloaded if restoring the add-on config fails."""
addon_options.update({"device": "/test", "s0_legacy_key": "old123"})
entry = integration
hass.config_entries.async_update_entry(
entry, unique_id="1234", data={**entry.data, "use_addon": True}
)
result = await entry.start_reconfigure_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "intent_reconfigure"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"use_addon": True}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_addon_reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"usb_path": "/new",
"s0_legacy_key": "old123",
},
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "start_addon"
assert entry.state is config_entries.ConfigEntryState.NOT_LOADED
# Restoring the original add-on options fails on the cleanup path.
set_addon_options.side_effect = SupervisorError("Boom")
hass.config_entries.flow.async_abort(result["flow_id"])
await hass.async_block_till_done()
# The entry is left unloaded instead of adopting the unconfirmed options.
assert entry.state is config_entries.ConfigEntryState.NOT_LOADED
@pytest.mark.usefixtures("supervisor", "addon_running", "restart_addon", "backup_nvm")
async def test_migrate_flow_abandoned_after_commit_keeps_new_config(
hass: HomeAssistant,
client: MagicMock,
integration: MockConfigEntry,
set_addon_options: AsyncMock,
get_server_version: AsyncMock,
) -> None:
"""Test abandoning after the migration commit doesn't revert the add-on."""
entry = integration
hass.config_entries.async_update_entry(
entry,
data={
"url": "ws://localhost:3000",
"use_addon": True,
"usb_path": "/old",
},
)
result = await entry.start_reconfigure_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "intent_migrate"}
)
with patch("pathlib.Path.write_bytes"):
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "instruct_unplug"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "choose_serial_port"
_set_home_id(get_server_version, 5678)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_USB_PATH: "/new"}
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "start_addon"
await hass.async_block_till_done()
set_addon_options.reset_mock()
# The migration commits the entry to the new adapter and starts the
# restore, then the user closes the dialog.
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "restore_nvm"
assert entry.data[CONF_USB_PATH] == "/new"
hass.config_entries.flow.async_abort(result["flow_id"])
await hass.async_block_till_done()
# The entry stays on the new adapter and the add-on config is not
# reverted to the old one.
assert entry.data[CONF_USB_PATH] == "/new"
for mock_call in set_addon_options.call_args_list:
assert mock_call.args[1].config.get(CONF_ADDON_DEVICE) != "/old"
@pytest.mark.usefixtures("supervisor", "addon_installed")
async def test_configure_addon_usb_ports_failure(
hass: HomeAssistant,