Story 4.4 (TDD, phcc, Suite gruen 167/167): - diagnostics.py: Whitelist statt redigiertem Dump — Options ohne Secrets, KEIN entry.data-Dump (Entity-ID), Stueck-ANZAHL je Kategorie (gegen Enum normalisiert, nie Namen), keine source/Namen; Substring-Test beweist kein Leak (AD-5/FR-6.4). - luna-pro-Security-Review: 2/5 Findings uebernommen, 3 verworfen (Evidenz im Ledger). Epic 4 (optionaler LLM-Ton) komplett: Optionen+Disclosure, Phraser, Client, Diagnostics. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
83 lines
3.3 KiB
Python
83 lines
3.3 KiB
Python
"""Story 4.4 — diagnostics whitelist + redaction (AD-5/NFR-6)."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import timedelta
|
|
|
|
from homeassistant.components.weather import WeatherEntityFeature
|
|
from homeassistant.config_entries import ConfigSubentryData
|
|
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
|
|
from homeassistant.util import dt as dt_util
|
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
|
|
|
from custom_components.what_to_wear.const import CONF_WEATHER_ENTITY, DOMAIN, default_options
|
|
from custom_components.what_to_wear.diagnostics import async_get_config_entry_diagnostics
|
|
|
|
ENTITY = "weather.home_muellerstrasse"
|
|
SECRET_KEY = "sk-supersecret-12345"
|
|
ITEM_NAME = "Michaels Arbeitshose"
|
|
|
|
|
|
def _set_weather(hass) -> None:
|
|
hass.states.async_set(
|
|
ENTITY, "cloudy",
|
|
{"temperature_unit": "°C", "wind_speed_unit": "km/h", "precipitation_unit": "mm",
|
|
"supported_features": WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY},
|
|
)
|
|
|
|
|
|
def _register_forecast(hass) -> None:
|
|
async def handler(call) -> ServiceResponse:
|
|
base = dt_util.now()
|
|
entries = [{"datetime": (base + timedelta(days=o)).replace(hour=12), "temperature": 8.0,
|
|
"templow": 5.0} for o in range(-1, 4)]
|
|
return {call.data["entity_id"]: {"forecast": entries}}
|
|
|
|
hass.services.async_register("weather", "get_forecasts", handler,
|
|
supports_response=SupportsResponse.ONLY)
|
|
|
|
|
|
def _item(name, cat):
|
|
return ConfigSubentryData(data={"name": name, "category": cat, "warmth": 3},
|
|
subentry_type="item", title=name, unique_id=None)
|
|
|
|
|
|
async def _setup(hass):
|
|
_set_weather(hass)
|
|
_register_forecast(hass)
|
|
opts = default_options()
|
|
opts.update({"llm_enabled": True, "llm_api_key": SECRET_KEY})
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN, data={CONF_WEATHER_ENTITY: ENTITY}, options=opts,
|
|
subentries_data=[_item(ITEM_NAME, "bottom"), _item("Shirt", "top"), _item("Boots", "shoes")],
|
|
)
|
|
entry.add_to_hass(hass)
|
|
assert await hass.config_entries.async_setup(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
return entry
|
|
|
|
|
|
async def test_diagnostics_never_leak_key_entity_or_names(hass: HomeAssistant) -> None:
|
|
entry = await _setup(hass)
|
|
diag = await async_get_config_entry_diagnostics(hass, entry)
|
|
blob = json.dumps(diag, default=str)
|
|
# the API key value must never appear
|
|
assert SECRET_KEY not in blob
|
|
# the weather entity id (a location hint) must never appear
|
|
assert ENTITY not in blob
|
|
# item names must never appear
|
|
assert ITEM_NAME not in blob
|
|
|
|
|
|
async def test_diagnostics_contains_useful_whitelist(hass: HomeAssistant) -> None:
|
|
entry = await _setup(hass)
|
|
diag = await async_get_config_entry_diagnostics(hass, entry)
|
|
# per-category counts (not names)
|
|
assert diag["item_counts"] == {"bottom": 1, "top": 1, "shoes": 1}
|
|
assert "recommendation" in diag
|
|
assert diag["recommendation"]["status"] == "ok"
|
|
assert "metrics" in diag["recommendation"]
|
|
# options present as a whitelist without the secret key
|
|
assert "llm_api_key" not in diag["options"]
|
|
assert "data" not in diag # entry.data (weather entity id) not dumped at all
|
|
assert "source" not in diag["recommendation"] # entity id never included
|