Add coverage-gap detection: ignored-hrefs registry, Repairs issue, redaction
Implements the first half of the device-capability-diagnostics spec: - registry/capabilities/ignored.py: known-noise hrefs (Bixby/voice provisioning, WiFi/BLE info, OTA/region housekeeping, and a couple of redundant hrefs) declared as no-entity Capability objects, so discover()'s existing unknown-resource reporting treats them as covered instead of flagging every device as having gaps. Folded into each by_type registry and into the global fallback CAPABILITIES set. - discovery.py: log() callback now passes the raw href instead of a formatted message, so callers can collect a clean unbound-hrefs list. - coordinator.py: wires that callback into self._unbound_hrefs, tracks device_type_name/one_ui_version, and raises (or clears) a Repairs issue when a device's type is unrecognized or it has genuinely unmodeled hrefs left over. - registry/redact.py: recursive, substring-keyed redaction for anything that looks like account/identity data, ahead of the diagnostics.py platform that will consume it. Verified against the real dishwasher/refrigerator fixtures: known-noise hrefs no longer show up as gaps, while genuine gaps (e.g. /bespoke/vs/0 on the fridge) still do.
This commit is contained in:
1 parent
b67e0e5cf5
commit
35b47d6b5a
14 files changed
+383
-11
No files matched your search
@@ -6,6 +6,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from custom_components.localthings.const import (
|
||||
DOMAIN, SUMMARY_INTERVAL_S,
|
||||
@@ -72,3 +73,84 @@ async def test_update_failed_on_persistent_poll_error(
|
||||
|
||||
# Setup fails — entry should not be in hass.data
|
||||
assert not result or mock_entry.entry_id not in hass.data.get(DOMAIN, {})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coverage-gap detection and the Repairs issue it raises
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def test_discovery_populates_device_type_and_unbound_hrefs(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||
) -> None:
|
||||
"""The real refrigerator fixture is a recognized type with some gaps left."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
assert coordinator.device_type_name == 'refrigerator'
|
||||
assert coordinator.one_ui_version == '7.0 Refrigerator'
|
||||
# Real, currently-unmodeled hrefs on this fixture (not noise — see
|
||||
# tests/test_registry_ignored.py for the noise-suppression checks).
|
||||
assert '/bespoke/vs/0' in coordinator._unbound_hrefs
|
||||
|
||||
|
||||
async def test_coverage_gap_issue_created_for_real_fixture_gaps(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||
) -> None:
|
||||
"""A recognized device with leftover unbound hrefs still raises the issue."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
issue_id = f"device_gap_{mock_entry.entry_id}"
|
||||
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None
|
||||
|
||||
|
||||
def test_update_coverage_gap_issue_creates_issue_for_unknown_type(
|
||||
hass: HomeAssistant, mock_entry
|
||||
) -> None:
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
|
||||
coordinator._update_coverage_gap_issue(True, [], 'Test Appliance')
|
||||
|
||||
issue_id = f"device_gap_{mock_entry.entry_id}"
|
||||
issue = ir.async_get(hass).async_get_issue(DOMAIN, issue_id)
|
||||
assert issue is not None
|
||||
assert issue.translation_key == 'device_gap'
|
||||
assert issue.translation_placeholders == {'device_name': 'Test Appliance'}
|
||||
assert issue.is_fixable is False
|
||||
|
||||
|
||||
def test_update_coverage_gap_issue_creates_issue_for_unbound_hrefs(
|
||||
hass: HomeAssistant, mock_entry
|
||||
) -> None:
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
|
||||
coordinator._update_coverage_gap_issue(False, ['/mystery/vs/0'], 'Test Appliance')
|
||||
|
||||
issue_id = f"device_gap_{mock_entry.entry_id}"
|
||||
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None
|
||||
|
||||
|
||||
def test_update_coverage_gap_issue_absent_when_fully_covered(
|
||||
hass: HomeAssistant, mock_entry
|
||||
) -> None:
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
|
||||
coordinator._update_coverage_gap_issue(False, [], 'Test Appliance')
|
||||
|
||||
issue_id = f"device_gap_{mock_entry.entry_id}"
|
||||
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
|
||||
|
||||
|
||||
def test_update_coverage_gap_issue_clears_previous_issue(
|
||||
hass: HomeAssistant, mock_entry
|
||||
) -> None:
|
||||
"""A later discovery run with no gap deletes an issue raised earlier."""
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
issue_id = f"device_gap_{mock_entry.entry_id}"
|
||||
|
||||
coordinator._update_coverage_gap_issue(True, [], 'Test Appliance')
|
||||
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None
|
||||
|
||||
coordinator._update_coverage_gap_issue(False, [], 'Test Appliance')
|
||||
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tests for registry.redact — the safety net for diagnostics downloads."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from custom_components.localthings.registry.batch import parse_device0_batch
|
||||
from custom_components.localthings.registry.redact import REDACTED, redact_resources
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parent / 'fixtures'
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
data = json.loads((FIXTURES / name).read_text())
|
||||
return parse_device0_batch(data['device0'])
|
||||
|
||||
|
||||
def test_redacts_known_sensitive_fields_in_dishwasher_dump():
|
||||
resources = _load('dishwasher_device.json')
|
||||
redacted = redact_resources(resources)
|
||||
|
||||
info = redacted['/information/vs/0']
|
||||
assert info['x.com.samsung.da.serialNum'] == REDACTED
|
||||
assert info['x.com.samsung.da.otnDUID'] == REDACTED
|
||||
|
||||
wireless = redacted['/wirelessinfo/vs/0']
|
||||
assert wireless['macaddressWiFi'] == REDACTED
|
||||
assert wireless['macaddressBLE'] == REDACTED
|
||||
|
||||
provisioning = redacted['/voice/provisioning/vs/0']
|
||||
headers = provisioning['voice.provisioning.headers']
|
||||
assert headers['login_id'] == REDACTED
|
||||
deviceinfo = provisioning['voice.provisioning.deviceinfo']
|
||||
assert deviceinfo['voice.provisioning.deviceinfo.accesstoken'] == REDACTED
|
||||
assert deviceinfo['voice.provisioning.deviceinfo.deviceid'] == REDACTED
|
||||
assert deviceinfo['voice.provisioning.deviceinfo.userid'] == REDACTED
|
||||
|
||||
|
||||
def test_ordinary_state_fields_survive_untouched():
|
||||
resources = _load('dishwasher_device.json')
|
||||
redacted = redact_resources(resources)
|
||||
|
||||
op_state = redacted['/operational/state/vs/0']
|
||||
assert op_state['x.com.samsung.da.state'] == 'Run'
|
||||
assert op_state['x.com.samsung.da.progress'] == 'Finish'
|
||||
|
||||
power = redacted['/power/vs/0']
|
||||
assert power['x.com.samsung.da.power'] == 'On'
|
||||
|
||||
dishwasher = redacted['/dishwasher/vs/0']
|
||||
assert dishwasher['x.com.samsung.da.sanitize'] == 'On'
|
||||
assert dishwasher['x.com.samsung.da.rinseLevel'] == '4'
|
||||
|
||||
alarms = redacted['/alarms/vs/0']['x.com.samsung.da.items']
|
||||
assert alarms[0]['x.com.samsung.da.code'] == 'SNSF_Reached'
|
||||
|
||||
|
||||
def test_redacts_known_sensitive_fields_in_refrigerator_dump():
|
||||
resources = _load('refrigerator_device.json')
|
||||
redacted = redact_resources(resources)
|
||||
|
||||
info = redacted['/information/vs/0']
|
||||
assert info['x.com.samsung.da.serialNum'] == REDACTED
|
||||
|
||||
wireless = redacted['/wirelessinfo/vs/0']
|
||||
assert wireless['macaddressWiFi'] == REDACTED
|
||||
assert wireless['macaddressBLE'] == REDACTED
|
||||
|
||||
|
||||
def test_redact_resources_does_not_mutate_input():
|
||||
resources = _load('dishwasher_device.json')
|
||||
original_serial = resources['/information/vs/0']['x.com.samsung.da.serialNum']
|
||||
|
||||
redact_resources(resources)
|
||||
|
||||
assert resources['/information/vs/0']['x.com.samsung.da.serialNum'] == original_serial
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Tests that known-noise hrefs don't show up as coverage gaps.
|
||||
|
||||
Each device-type registry folds in registry.capabilities.ignored.IGNORED so
|
||||
that Bixby/voice/network/OTA housekeeping resources aren't reported as
|
||||
unbound. This guards two things at once: that the ignored hrefs are
|
||||
actually suppressed, and (via a plain import) that no ignored href
|
||||
collides with a real capability declared elsewhere for the same family.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from custom_components.localthings.registry.batch import parse_device0_batch
|
||||
from custom_components.localthings.registry.by_type import dishwasher, refrigerator
|
||||
from custom_components.localthings.registry.capabilities.ignored import IGNORED
|
||||
|
||||
FIXTURES = Path(__file__).resolve().parent / 'fixtures'
|
||||
|
||||
_IGNORED_HREFS = {cap.href for cap in IGNORED}
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
data = json.loads((FIXTURES / name).read_text())
|
||||
return parse_device0_batch(data['device0'])
|
||||
|
||||
|
||||
def _unbound_hrefs(resources, registry) -> list[str]:
|
||||
unbound = []
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
discover(resources, registry.capabilities, registry.pattern_capabilities,
|
||||
log=unbound.append)
|
||||
return unbound
|
||||
|
||||
|
||||
def test_ignored_hrefs_absent_from_dishwasher_gaps():
|
||||
resources = _load('dishwasher_device.json')
|
||||
unbound = _unbound_hrefs(resources, dishwasher.REGISTRY)
|
||||
present_ignored = _IGNORED_HREFS & set(unbound)
|
||||
assert not present_ignored, f"ignored hrefs leaked into gap report: {present_ignored}"
|
||||
|
||||
|
||||
def test_ignored_hrefs_absent_from_refrigerator_gaps():
|
||||
resources = _load('refrigerator_device.json')
|
||||
unbound = _unbound_hrefs(resources, refrigerator.REGISTRY)
|
||||
present_ignored = _IGNORED_HREFS & set(unbound)
|
||||
assert not present_ignored, f"ignored hrefs leaked into gap report: {present_ignored}"
|
||||
|
||||
|
||||
def test_dishwasher_fixture_still_has_genuine_gaps():
|
||||
"""Sanity check that the gap mechanism isn't silencing everything.
|
||||
|
||||
If this starts failing because the fixture is now fully covered, that's
|
||||
good news — just trim the expected set down (or to empty) rather than
|
||||
deleting the test.
|
||||
"""
|
||||
resources = _load('dishwasher_device.json')
|
||||
unbound = set(_unbound_hrefs(resources, dishwasher.REGISTRY))
|
||||
assert '/wm/jobbeginingstatus/vs/0' in unbound
|
||||
assert '/diagnosis/vs/0' in unbound
|
||||
Reference in new issue
Block a user