what_to_wear/tests/test_llm_options.py
Nora aae8c38a20 security(gate3): Security-Review-Haertung ueber 4 Pakete (luna-pro)
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>
2026-07-13 16:01:31 +00:00

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