Phase 6 / Gate 3 — Security-Review auf den fertigen Code, 4 thematische Pakete: - P1 Secrets+Injection (client/phraser/diagnostics): validate haertet URL/Entity/ Unicode-Umgehungen (// , &entity; , Cc/Cf/Zl/Zp); kein Key-Leak. - P2 XSS+externe (card/ha_entity/frontend): kein XSS; DoS-Caps (Forecast 400, Karte 60). - P3 Mutator+Startup (coordinator/__init__/const): nicht-blockierender Startup via Background-Task, LLM-/Store-Ops defensiv, weather_entity_id-Guard, build_items NaN/inf+Caps, Warn-Log ohne Secret. - P4 Flows (config_flow): test_entity exception-frei, _clean_item defensiv, finite Bandgrenzen + sanitized_options-Normalisierung, echte bool-Coercion, Typ-Log. - 24 Findings, 14 uebernommen (mit Regressionstests), 10 verworfen mit Evidenz. Suite gruen 188/188. Gate 3 BESTANDEN. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
106 lines
4.2 KiB
Python
106 lines
4.2 KiB
Python
"""Story 4.1 — LLM options section + key handling (AD-5)."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import pathlib
|
|
|
|
from custom_components.what_to_wear.config_flow import _build_options
|
|
from custom_components.what_to_wear.const import default_options
|
|
|
|
TR_DIR = pathlib.Path(__file__).parent.parent / "custom_components" / "what_to_wear" / "translations"
|
|
|
|
|
|
def _base_input(**over):
|
|
base = {
|
|
"cold_sensitivity_offset": 0, "switchover_time": "10:00:00",
|
|
"warmth_band_0": 0.0, "warmth_band_1": 8.0, "warmth_band_2": 15.0, "warmth_band_3": 22.0,
|
|
"heat_threshold": 28.0, "rain_prob_should": 40, "rain_prob_must": 70,
|
|
"rain_amount_should": 1.0, "rain_amount_must": 5.0, "gust_should": 40, "wind_proxy_should": 30,
|
|
"uv_should": 6,
|
|
}
|
|
base.update(over)
|
|
return base
|
|
|
|
|
|
def test_enable_llm_stores_provider_and_model() -> None:
|
|
current = default_options()
|
|
opts, err = _build_options(
|
|
_base_input(llm_enabled=True, llm_provider="anthropic", llm_model="claude-haiku-4-5",
|
|
llm_api_key="secret-key"),
|
|
current,
|
|
)
|
|
assert err is None
|
|
assert opts["llm_enabled"] is True
|
|
assert opts["llm_provider"] == "anthropic"
|
|
assert opts["llm_model"] == "claude-haiku-4-5"
|
|
assert opts["llm_api_key"] == "secret-key"
|
|
|
|
|
|
def test_empty_key_keeps_existing() -> None:
|
|
current = {**default_options(), "llm_api_key": "existing-key", "llm_enabled": True}
|
|
# user re-opens options, changes nothing about the key (empty field)
|
|
opts, err = _build_options(_base_input(llm_enabled=True, llm_provider="openai"), current)
|
|
assert err is None
|
|
assert opts["llm_api_key"] == "existing-key" # not wiped
|
|
|
|
|
|
def test_disable_clears_key() -> None:
|
|
# AD-5: disabling the LLM tone is the way to remove the stored key.
|
|
current = {**default_options(), "llm_api_key": "existing-key", "llm_enabled": True}
|
|
opts, err = _build_options(_base_input(llm_enabled=False), current)
|
|
assert err is None
|
|
assert opts["llm_enabled"] is False
|
|
assert opts["llm_api_key"] == "" # cleared on disable
|
|
|
|
|
|
def test_whitespace_key_does_not_overwrite() -> None:
|
|
current = {**default_options(), "llm_api_key": "existing-key", "llm_enabled": True}
|
|
opts, err = _build_options(_base_input(llm_enabled=True, llm_api_key=" "), current)
|
|
assert err is None
|
|
assert opts["llm_api_key"] == "existing-key" # whitespace treated as empty
|
|
|
|
|
|
def test_invalid_provider_coerced() -> None:
|
|
opts, err = _build_options(
|
|
_base_input(llm_enabled=True, llm_provider="evilcorp", llm_api_key="k"), default_options()
|
|
)
|
|
assert err is None
|
|
assert opts["llm_provider"] == "openai" # coerced to a valid provider
|
|
|
|
|
|
def test_default_off() -> None:
|
|
opts, err = _build_options(_base_input(), default_options())
|
|
assert err is None
|
|
assert opts["llm_enabled"] is False
|
|
|
|
|
|
def test_disclosure_present_in_translations() -> None:
|
|
for lang in ("en", "de"):
|
|
d = json.loads((TR_DIR / f"{lang}.json").read_text(encoding="utf-8"))
|
|
desc = d["options"]["step"]["init"]["description"]
|
|
# the disclosure names the transferred data and the storage caveat
|
|
assert "OpenAI" in desc and "Anthropic" in desc
|
|
low = desc.lower()
|
|
assert ("privacy" in low) or ("datenschutz" in low)
|
|
assert ("backup" in low)
|
|
|
|
|
|
def test_string_false_disables_and_clears_key() -> None:
|
|
# a manipulated submit with llm_enabled="false" must disable + clear (Gate 3)
|
|
current = {**default_options(), "llm_api_key": "existing-key", "llm_enabled": True}
|
|
opts, err = _build_options(_base_input(llm_enabled="false"), current)
|
|
assert err is None
|
|
assert opts["llm_enabled"] is False
|
|
assert opts["llm_api_key"] == ""
|
|
|
|
|
|
def test_infinite_band_edge_rejected() -> None:
|
|
opts, err = _build_options(_base_input(warmth_band_3=float("inf")), default_options())
|
|
assert opts is None and err == "invalid_input"
|
|
|
|
|
|
def test_out_of_range_threshold_normalized() -> None:
|
|
# a manipulated over-range rain probability is normalized to the default
|
|
opts, err = _build_options(_base_input(rain_prob_should=9999), default_options())
|
|
assert err is None
|
|
assert opts["rain_prob_should"] == 40 # sanitized back to default
|