M6-T02: add DSMR + Tibber flat config fields

- Settings: dsmr_ingest_enabled, dsmr_mqtt_topic, dsmr_sample_interval_s,
  tibber_api_token (secret), tibber_home_id (all opt-in / off by default).
- CONFIG_FIELDS DSMR + Tibber sections; _settings_payload extended.
- .env.example examples; test_api_config coverage (sections, secret, 422).
This commit is contained in:
2026-06-23 20:43:46 +02:00
parent 18fe18281b
commit df54f5518b
5 changed files with 257 additions and 1 deletions
+206
View File
@@ -572,6 +572,7 @@ EXPECTED_CHECKBOX_FIELDS = {
"MQTT_TLS_ENABLED",
"HA_DISCOVERY_ENABLED",
"MODBUS_POLLING_ENABLED",
"DSMR_INGEST_ENABLED",
}
@@ -703,6 +704,211 @@ def test_put_config_mqtt_reconnect_uses_db_merged_settings(
)
# ---------------------------------------------------------------------------
# M6-T02: DSMR + Tibber CONFIG_FIELDS
# ---------------------------------------------------------------------------
def test_get_config_includes_dsmr_section(client: TestClient) -> None:
"""GET /api/config must include a DSMR section with expected fields."""
_login(client)
response = client.get("/api/config")
body = response.json()
section_names = {s["name"] for s in body["sections"]}
assert "DSMR" in section_names, f"DSMR section missing; got {section_names}"
dsmr_section = next(s for s in body["sections"] if s["name"] == "DSMR")
env_names = {f["env_name"] for f in dsmr_section["fields"]}
assert "DSMR_INGEST_ENABLED" in env_names
assert "DSMR_MQTT_TOPIC" in env_names
assert "DSMR_SAMPLE_INTERVAL_S" in env_names
def test_get_config_includes_tibber_section(client: TestClient) -> None:
"""GET /api/config must include a Tibber section with expected fields."""
_login(client)
response = client.get("/api/config")
body = response.json()
section_names = {s["name"] for s in body["sections"]}
assert "Tibber" in section_names, f"Tibber section missing; got {section_names}"
tibber_section = next(s for s in body["sections"] if s["name"] == "Tibber")
env_names = {f["env_name"] for f in tibber_section["fields"]}
assert "TIBBER_API_TOKEN" in env_names
assert "TIBBER_HOME_ID" in env_names
def test_get_config_tibber_api_token_is_secret(client: TestClient) -> None:
"""TIBBER_API_TOKEN must be marked secret and return empty string in GET response."""
_login(client)
response = client.get("/api/config")
body = response.json()
tibber_section = next(s for s in body["sections"] if s["name"] == "Tibber")
token_field = next(f for f in tibber_section["fields"] if f["env_name"] == "TIBBER_API_TOKEN")
assert token_field["secret"] is True
assert token_field["value"] == "", (
f"TIBBER_API_TOKEN should be masked (empty string), got {token_field['value']!r}"
)
def test_get_config_dsmr_ingest_enabled_is_checkbox(client: TestClient) -> None:
"""DSMR_INGEST_ENABLED must have input_type='checkbox' for correct frontend rendering."""
_login(client)
response = client.get("/api/config")
body = response.json()
dsmr_section = next(s for s in body["sections"] if s["name"] == "DSMR")
enabled_field = next(f for f in dsmr_section["fields"] if f["env_name"] == "DSMR_INGEST_ENABLED")
assert enabled_field["input_type"] == "checkbox", (
f"DSMR_INGEST_ENABLED input_type should be 'checkbox', got {enabled_field['input_type']!r}"
)
def test_get_config_dsmr_sample_interval_input_type_is_number(client: TestClient) -> None:
"""DSMR_SAMPLE_INTERVAL_S must have input_type='number'."""
_login(client)
response = client.get("/api/config")
body = response.json()
dsmr_section = next(s for s in body["sections"] if s["name"] == "DSMR")
interval_field = next(
f for f in dsmr_section["fields"] if f["env_name"] == "DSMR_SAMPLE_INTERVAL_S"
)
assert interval_field["input_type"] == "number", (
f"DSMR_SAMPLE_INTERVAL_S input_type should be 'number', got {interval_field['input_type']!r}"
)
def test_put_config_blank_tibber_api_token_keeps_existing(
client: TestClient, test_database_urls
) -> None:
"""Submitting blank TIBBER_API_TOKEN must NOT overwrite the stored secret."""
_login(client)
# Store a secret via full PUT
payload_with_secret = _full_config_payload({"TIBBER_API_TOKEN": "tibber-secret-token"})
resp1 = client.put(
"/api/config",
json={"updates": payload_with_secret},
headers={"X-CSRF-Token": "token"},
)
assert resp1.status_code == 200, f"First PUT failed: {resp1.status_code} {resp1.text}"
# PUT with blank for that secret (keep-old semantics)
payload_blank_secret = _full_config_payload({"TIBBER_API_TOKEN": ""})
resp2 = client.put(
"/api/config",
json={"updates": payload_blank_secret},
headers={"X-CSRF-Token": "token"},
)
assert resp2.status_code == 200, f"Second PUT failed: {resp2.status_code} {resp2.text}"
# The stored value in the DB should still be the original secret
conn = sqlite3.connect(test_database_urls["app_path"])
try:
rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall())
finally:
conn.close()
assert rows.get("TIBBER_API_TOKEN") == "tibber-secret-token"
def test_put_config_new_tibber_api_token_overwrites_existing(
client: TestClient, test_database_urls
) -> None:
"""Submitting a non-blank TIBBER_API_TOKEN must overwrite the stored secret."""
_login(client)
# Store initial secret
payload_initial = _full_config_payload({"TIBBER_API_TOKEN": "old-tibber-token"})
resp1 = client.put(
"/api/config",
json={"updates": payload_initial},
headers={"X-CSRF-Token": "token"},
)
assert resp1.status_code == 200
# Overwrite with a new non-blank secret
payload_new = _full_config_payload({"TIBBER_API_TOKEN": "new-tibber-token"})
resp2 = client.put(
"/api/config",
json={"updates": payload_new},
headers={"X-CSRF-Token": "token"},
)
assert resp2.status_code == 200
conn = sqlite3.connect(test_database_urls["app_path"])
try:
rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall())
finally:
conn.close()
assert rows.get("TIBBER_API_TOKEN") == "new-tibber-token"
def test_put_config_invalid_dsmr_sample_interval_returns_422_and_does_not_write(
client: TestClient, test_database_urls
) -> None:
"""Non-integer DSMR_SAMPLE_INTERVAL_S must return 422 and not persist the bad value."""
_login(client)
payload = _full_config_payload({"DSMR_SAMPLE_INTERVAL_S": "not-a-number"})
response = client.put(
"/api/config",
json={"updates": payload},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 422
conn = sqlite3.connect(test_database_urls["app_path"])
try:
rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall())
finally:
conn.close()
assert rows.get("DSMR_SAMPLE_INTERVAL_S") != "not-a-number"
def test_get_config_tibber_api_token_value_masked_after_save(
client: TestClient, test_database_urls
) -> None:
"""After saving a TIBBER_API_TOKEN, the value must remain masked (empty string) in GET response."""
_login(client)
# Save a non-blank token
payload = _full_config_payload({"TIBBER_API_TOKEN": "some-tibber-token"})
put_resp = client.put(
"/api/config",
json={"updates": payload},
headers={"X-CSRF-Token": "token"},
)
assert put_resp.status_code == 200
# After saving: value must still be masked (empty string), configured must be True
resp_after = client.get("/api/config")
body_after = resp_after.json()
tibber_section_after = next(s for s in body_after["sections"] if s["name"] == "Tibber")
token_field_after = next(
f for f in tibber_section_after["fields"] if f["env_name"] == "TIBBER_API_TOKEN"
)
assert token_field_after["configured"] is True
assert token_field_after["value"] == "", "Secret value must remain masked after save"
# The actual token must not appear anywhere in the response body
assert "some-tibber-token" not in resp_after.text
def test_post_mqtt_test_uses_db_broker_host(
client: TestClient, test_database_urls
) -> None: