diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 7f0401938d9..f2469c7bff9 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -284,13 +284,13 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): await self._async_validate_mac_abort_configured( mac_address, self._host, self._port ) - return await self.async_step_discovery_confirm() async def _async_validate_mac_abort_configured( self, formatted_mac: str, host: str, port: int | None ) -> None: """Validate if the MAC address is already configured.""" + assert self.unique_id is not None if not ( entry := self.hass.config_entries.async_entry_for_domain_unique_id( self.handler, formatted_mac @@ -459,11 +459,12 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): async def async_step_validated_connection(self) -> ConfigFlowResult: """Handle validated connection.""" + assert self.unique_id is not None + assert self._host is not None + assert self._device_name is not None + if self.source == SOURCE_RECONFIGURE: - assert self.unique_id is not None assert self._reconfig_entry.unique_id is not None - assert self._host is not None - assert self._device_name is not None placeholders = { "name": self._reconfig_entry.data.get( CONF_DEVICE_NAME, self._reconfig_entry.title @@ -500,6 +501,22 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): data=self._reconfig_entry.data | self._async_make_config_data(), ) if self.source == SOURCE_REAUTH: + if self.unique_id != self._reauth_entry.unique_id: + await self._async_validate_mac_abort_configured( + format_mac(self.unique_id), self._host, self._port + ) + return self.async_abort( + reason="reauth_unique_id_changed", + description_placeholders={ + "name": self._reauth_entry.data.get( + CONF_DEVICE_NAME, self._reauth_entry.title + ), + "host": self._host, + "expected_mac": format_mac(self._reauth_entry.unique_id), + "unexpected_mac": format_mac(self.unique_id), + "unexpected_device_name": self._device_name, + }, + ) return self.async_update_reload_and_abort( self._reauth_entry, data=self._reauth_entry.data | self._async_make_config_data(), @@ -563,7 +580,6 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): zeroconf_instance=zeroconf_instance, noise_psk=noise_psk, ) - try: await cli.connect() self._device_info = await cli.device_info() diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index fbe8496c9be..915f2036b2c 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -12,6 +12,7 @@ "mqtt_missing_payload": "Missing MQTT Payload.", "name_conflict_migrated": "The configuration for `{name}` has been migrated to a new device with MAC address `{mac}` from `{existing_mac}`.", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "reauth_unique_id_changed": "**Re-authentication of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`).", "reconfigure_name_conflict": "**Reconfiguration of `{name}` was aborted** because the address `{host}` points to a device named `{name}` (MAC: `{expected_mac}`), which is already in use by another configuration entry: `{existing_title}`.", "reconfigure_unique_id_changed": "**Reconfiguration of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`)." }, diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 440e52700b1..c55b595faeb 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -813,12 +813,15 @@ async def test_reauth_confirm_valid( entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) result = await entry.start_reauth_flow(hass) - mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} ) @@ -828,6 +831,41 @@ async def test_reauth_confirm_valid( assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reauth_attempt_to_change_mac_aborts( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reauth initiation with valid PSK attempting to change mac. + + This can happen if reauth starts, but they don't finish it before + a new device takes the place of the old one at the same IP. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_unique_id_changed" + assert CONF_NOISE_PSK not in entry.data + + @pytest.mark.usefixtures("mock_zeroconf") async def test_reauth_fixed_via_dashboard( hass: HomeAssistant, @@ -845,10 +883,13 @@ async def test_reauth_fixed_via_dashboard( CONF_PASSWORD: "", CONF_DEVICE_NAME: "test", }, + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) - mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) mock_dashboard["configured"].append( { @@ -883,7 +924,7 @@ async def test_reauth_fixed_via_dashboard_add_encryption_remove_password( """Test reauth fixed automatically via dashboard with password removed.""" mock_client.device_info.side_effect = ( InvalidAuthAPIError, - DeviceInfo(uses_password=False, name="test"), + DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:aa"), ) mock_dashboard["configured"].append( @@ -917,7 +958,9 @@ async def test_reauth_fixed_via_remove_password( mock_setup_entry: None, ) -> None: """Test reauth fixed automatically by seeing password removed.""" - mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) result = await mock_config_entry.start_reauth_flow(hass) @@ -943,10 +986,13 @@ async def test_reauth_fixed_via_dashboard_at_confirm( CONF_PASSWORD: "", CONF_DEVICE_NAME: "test", }, + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) - mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test") + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) result = await entry.start_reauth_flow(hass) @@ -984,6 +1030,7 @@ async def test_reauth_confirm_invalid( entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) @@ -1000,7 +1047,9 @@ async def test_reauth_confirm_invalid( assert result["errors"]["base"] == "invalid_psk" mock_client.device_info = AsyncMock( - return_value=DeviceInfo(uses_password=False, name="test") + return_value=DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -1019,7 +1068,7 @@ async def test_reauth_confirm_invalid_with_unique_id( entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, - unique_id="test", + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) @@ -1036,7 +1085,9 @@ async def test_reauth_confirm_invalid_with_unique_id( assert result["errors"]["base"] == "invalid_psk" mock_client.device_info = AsyncMock( - return_value=DeviceInfo(uses_password=False, name="test") + return_value=DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:aa" + ) ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} @@ -1049,7 +1100,7 @@ async def test_reauth_confirm_invalid_with_unique_id( @pytest.mark.usefixtures("mock_zeroconf") async def test_reauth_encryption_key_removed( - hass: HomeAssistant, mock_client, mock_setup_entry: None + hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None ) -> None: """Test reauth when the encryption key was removed.""" entry = MockConfigEntry( @@ -1060,7 +1111,7 @@ async def test_reauth_encryption_key_removed( CONF_PASSWORD: "", CONF_NOISE_PSK: VALID_NOISE_PSK, }, - unique_id="test", + unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) @@ -1715,3 +1766,129 @@ async def test_user_flow_name_conflict_overwrite( CONF_DEVICE_NAME: "test", } assert result["context"]["unique_id"] == "11:22:33:44:55:aa" + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_attempt_to_change_mac_aborts( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation with valid PSK attempting to change mac.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="other", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_unique_id_changed" + assert CONF_NOISE_PSK not in entry.data + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_name_conflict_migrate( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation when device has been replaced.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "name_conflict" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "name_conflict_migrate"} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "name_conflict_migrated" + + assert entry.data == { + CONF_HOST: "127.0.0.2", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + CONF_DEVICE_NAME: "test", + } + assert entry.unique_id == "11:22:33:44:55:bb" + + +@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry") +async def test_reconfig_name_conflict_overwrite( + hass: HomeAssistant, mock_client: APIClient +) -> None: + """Test reconfig initiation when device has been replaced.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_DEVICE_NAME: "test", + }, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + mock_client.device_info.return_value = DeviceInfo( + uses_password=False, name="test", mac_address="11:22:33:44:55:bb" + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "name_conflict" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "name_conflict_overwrite"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + assert result["data"] == { + CONF_HOST: "127.0.0.2", + CONF_PORT: 6053, + CONF_PASSWORD: "", + CONF_NOISE_PSK: "", + CONF_DEVICE_NAME: "test", + } + assert result["context"]["unique_id"] == "11:22:33:44:55:bb" + assert ( + hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, "11:22:33:44:55:aa" + ) + is None + )