mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
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:
co-authored by
Paulus Schoutsen
Claude Fable 5
parent
1fb1057c73
commit
5b8bee1419
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user