Address code review: close the migration-window duplicate and unify adoption

Two real findings from review of the identity change.

A pre-v4 entry keeps its serial-keyed unique_id until its first live poll
adopts the UUID, which can be a long while for an appliance that is off
since the entry loads from its snapshot meanwhile. The config flow's UUID
check couldn't see such an entry, so re-adding the same appliance in that
window was waved through as a second entry -- and the two would collide the
moment the older one re-keyed, with rekey_entry resolving the collision by
deleting the duplicate rows, taking the original's entity_ids, history and
automations with them. The flow now also aborts on the legacy key, matched
together with the host so issue #381's two same-serial units at different
addresses stay separable, and gated on CONF_DEVICE_KEY being absent so the
check disappears once the entry has migrated.

Separately, the "nothing stored yet" branch adopted the polled identity
unconditionally, silently dropping the "same IP, different appliance" guard
_run_discovery has had since issue #236 -- and dropping it for precisely
the users who have been running longest. _resolve_identity now computes the
key an entry currently carries once and applies one corroboration rule to
it, so a pre-v4 entry defends itself exactly as a migrated one does.
Adoption still needs no corroboration where there is no identity claim to
defend: an entry keyed on its address (issues #83/#189) or with no stored
serial at all.

Two tests had to change with it, both because their setup was unfaithful
rather than because the behaviour regressed: the two-unit test now has its
devices report the shared serial they actually report, and the offline test
now models a placeholder-serial board, which is where an entry's key and a
snapshot's serial can genuinely disagree.

Three new tests cover the changed behaviour, and the mutation sweep is
extended to thirteen breakages -- including the two guards added here and
the host match that keeps the duplicate check from undoing #381's fix.

Also from review: rename three stale _resolve_key doc references to
_resolve_identity, and stop rebinding new_unique_id in rekey_entry.
This commit is contained in:
Marc Billow committed 2026-08-17 05:24:22 +00:00
1 parent 1e9fbd4ec5
commit c7aa66ef6e
5 files changed
+206 -70

No files matched your search

+66
View File
@@ -1115,6 +1115,72 @@ async def test_same_serial_on_two_units_is_not_a_duplicate(hass: HomeAssistant,
assert result["data"][CONF_SERIAL] == first.data[CONF_SERIAL]
async def test_re_adding_during_the_migration_window_is_still_a_duplicate(
hass: HomeAssistant, mock_probe
) -> None:
"""An entry created before v4 keeps its serial-keyed unique_id until its
first *live* poll adopts the UUID, which can be a long while for an
appliance that is off (it loads from its snapshot meanwhile, issue
#295). The UUID check can't see such an entry, so without a second
check on the legacy key, re-adding this very appliance in that window
would be waved through -- and the two entries would collide the moment
the older one re-keyed, with rekey_entry resolving the collision by
deleting the duplicate rows and taking the original's entity_ids,
history and automations with them.
"""
existing = MockConfigEntry(
domain=DOMAIN,
data={k: v for k, v in ENTRY_DATA.items() if k != CONF_DEVICE_KEY},
unique_id=f"localthings_{MOCK_SERIAL}",
version=3,
)
existing.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: ENTRY_DATA[CONF_HOST]}
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_the_migration_window_check_still_separates_two_same_serial_units(
hass: HomeAssistant, mock_probe
) -> None:
"""The legacy-key check above matches on the host as well as the serial,
so it cannot undo the fix: issue #381's two units share a serial but sit
at different addresses, and the second must still be addable while the
first is mid-migration."""
existing = MockConfigEntry(
domain=DOMAIN,
data={
**{k: v for k, v in ENTRY_DATA.items() if k != CONF_DEVICE_KEY},
CONF_HOST: "192.168.0.3",
},
unique_id=f"localthings_{MOCK_SERIAL}",
version=3,
)
existing.add_to_hass(hass)
second_probe = {
**_probe_result(recognized=True),
"device_key": "3771f8bf-c184-3a2d-d885-e4c9818736d2",
"serial": MOCK_SERIAL,
}
with patch(
"custom_components.localthings.config_flow._probe_and_validate",
return_value=second_probe,
):
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: "192.168.0.14"}
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_DEVICE_KEY] == "3771f8bf-c184-3a2d-d885-e4c9818736d2"
def test_probe_reads_the_device_key_from_oic_d_without_an_extra_round_trip(monkeypatch):
"""`_read_device` already fetches /oic/p and /oic/d for the device-type
signal, so keying on the OCF UUID costs no additional GET -- it reads
+64 -18
View File
@@ -114,6 +114,14 @@ def _entry(
return entry
def _reporting_serial(resources: dict, serial: str) -> dict:
"""`resources` with the serialNum the device reports swapped out, so a
test can pair a fixture with the identity its scenario implies."""
info = dict(resources["/information/vs/0"])
info["x.com.samsung.da.serialNum"] = serial
return {**resources, "/information/vs/0": info}
def _seed_registry(hass: HomeAssistant, entry: MockConfigEntry, key: str, **entity_kwargs):
"""A device and one entity keyed on `key`, as a running install has."""
dev_reg = dr.async_get(hass)
@@ -229,10 +237,14 @@ async def test_the_two_units_from_the_issue_migrate_to_separate_identities(
including their entity unique_ids, which is where issue #83's Bug 4
silently dropped the second unit's entities even once its entry existed.
"""
# Both units report the shared serial, as the real ones do -- so the
# entry's stored identity still matches what the device says, and only
# the UUID separates them.
resources = _reporting_serial(fridge_resources, SHARED_SERIAL)
first = _entry(hass, version=3, key=SHARED_SERIAL, host="192.168.0.3")
_, first_entity = _seed_registry(hass, first, SHARED_SERIAL, suggested_object_id="purifier_a")
with _reachable(fridge_resources, UUID_A):
with _reachable(resources, UUID_A):
await hass.config_entries.async_setup(first.entry_id)
await hass.async_block_till_done()
@@ -243,7 +255,7 @@ async def test_the_two_units_from_the_issue_migrate_to_separate_identities(
second = _entry(hass, version=3, key=SHARED_SERIAL, host="192.168.0.14")
_, second_entity = _seed_registry(hass, second, SHARED_SERIAL, suggested_object_id="purifier_b")
with _reachable(fridge_resources, UUID_B):
with _reachable(resources, UUID_B):
await hass.config_entries.async_setup(second.entry_id)
await hass.async_block_till_done()
@@ -453,14 +465,16 @@ async def test_an_offline_load_never_rewrites_the_registry(
snapshot says, because that is last run's answer rather than the
device's.
Constructed so the two disagree: the snapshot was banked against a
device reporting one serial, while the entry is registered under
another. A replay that trusted the snapshot would rewrite every
registry row to match it -- without the appliance having been reachable
at any point -- and would then freeze that answer into CONF_DEVICE_KEY,
so the real UUID could never be adopted afterwards.
Modelled on a placeholder-serial board (issues #83/#189), which is
where the two can genuinely disagree: the entry is keyed on its address
because the board reports no usable serial, while the snapshot banked
whatever serial the polled resources carried. A replay that trusted the
snapshot would rewrite every registry row onto that serial -- without
the appliance having been reachable at any point -- and would then
freeze the answer into CONF_DEVICE_KEY, so the real UUID could never be
adopted afterwards.
"""
entry = _entry(hass, version=3, key=MOCK_SERIAL)
entry = _entry(hass, version=3, key=MOCK_HOST)
with _reachable(fridge_resources, None):
await hass.config_entries.async_setup(entry.entry_id)
@@ -468,30 +482,29 @@ async def test_an_offline_load_never_rewrites_the_registry(
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
# The entry is registered under a different identity than the snapshot's,
# and back on the pre-v4 shape the upgrade finds.
# Back to the pre-v4 shape the upgrade finds, still keyed on the address.
hass.config_entries.async_update_entry(
entry,
data={
**{k: v for k, v in entry.data.items() if k != CONF_DEVICE_KEY},
CONF_SERIAL: "LEGACY-KEY",
CONF_SERIAL: MOCK_HOST,
},
unique_id=f"{DOMAIN}_LEGACY-KEY",
unique_id=f"{DOMAIN}_{MOCK_HOST}",
version=3,
)
device, existing = _seed_registry(hass, entry, "LEGACY-KEY")
device, existing = _seed_registry(hass, entry, MOCK_HOST)
with _unreachable():
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
assert coordinator.device_key == "LEGACY-KEY"
assert coordinator.device_key == MOCK_HOST
assert CONF_DEVICE_KEY not in entry.data
assert entry.unique_id == f"{DOMAIN}_LEGACY-KEY"
assert dr.async_get(hass).async_get(device.id).identifiers == {(DOMAIN, "LEGACY-KEY")}
assert entry.unique_id == f"{DOMAIN}_{MOCK_HOST}"
assert dr.async_get(hass).async_get(device.id).identifiers == {(DOMAIN, MOCK_HOST)}
assert er.async_get(hass).async_get(existing.entity_id).unique_id == (
f"{DOMAIN}_LEGACY-KEY_connection_mode"
f"{DOMAIN}_{MOCK_HOST}_connection_mode"
)
# And the deferred adoption still works once the appliance answers --
@@ -662,6 +675,39 @@ async def test_a_different_appliance_on_the_same_address_keeps_the_registered_id
assert entry.data[CONF_SERIAL] == "SOME-OTHER-APPLIANCE"
async def test_a_pre_v4_entry_defends_itself_against_a_different_appliance(
hass: HomeAssistant, fridge_resources
) -> None:
"""The "same IP, different appliance" guard applies to an entry that has
not migrated yet, exactly as it does to one that has.
A pre-v4 entry is the population this change exists to move, but it is
also the population that has been running longest -- so it is the last
one that should hand its entity_ids, history and automations to an
appliance that merely happens to have taken over its address. Adoption
is the migration's job only when the identity is corroborated.
"""
entry = _entry(hass, version=3, key=MOCK_SERIAL)
device, existing = _seed_registry(hass, entry, MOCK_SERIAL)
# Neither the serial nor (therefore) the UUID belongs to the registered
# appliance.
resources = _reporting_serial(fridge_resources, "SOME-OTHER-APPLIANCE")
with _reachable(resources, UUID_B):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
assert coordinator.device_key == MOCK_SERIAL
assert entry.data[CONF_DEVICE_KEY] == MOCK_SERIAL
assert entry.data[CONF_SERIAL] == MOCK_SERIAL
assert entry.unique_id == f"{DOMAIN}_{MOCK_SERIAL}"
assert dr.async_get(hass).async_get(device.id).identifiers == {(DOMAIN, MOCK_SERIAL)}
assert er.async_get(hass).async_get(existing.entity_id).unique_id == (
f"{DOMAIN}_{MOCK_SERIAL}_connection_mode"
)
# ---------------------------------------------------------------------------
# rekey_entry's own contract
# ---------------------------------------------------------------------------