"""Tests for M2-T01: GET /api/config and PUT /api/config. Tests for M2-T05: POST /api/config/smtp/test.""" from __future__ import annotations import sqlite3 from unittest.mock import MagicMock, patch from fastapi.testclient import TestClient from app.config import get_settings from app.services.config_page import CONFIG_FIELDS from app.services.email import EmailConfigurationError, EmailDeliveryError # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _login(client: TestClient) -> None: """Log in as admin/test-password using the JSON API.""" resp = client.post( "/api/auth/login", json={"username": "admin", "password": "test-password"}, ) assert resp.status_code == 200, f"Login failed: {resp.status_code}" def _stringify(value) -> str: if value is None: return "" if isinstance(value, bool): return str(value).lower() return str(value) def _full_config_payload(overrides: dict[str, str] | None = None) -> dict[str, str]: """Build a complete env_name→value dict mirroring the Jinja form's full submission. Secrets default to "" (keep-old semantics). Non-secrets use current settings defaults. """ settings = get_settings() payload: dict[str, str] = {} for field in CONFIG_FIELDS: if field.secret: payload[field.env_name] = "" # blank → keep existing else: payload[field.env_name] = _stringify(getattr(settings, field.setting_attr)) if overrides: payload.update(overrides) return payload # --------------------------------------------------------------------------- # GET /api/config — unauthenticated # --------------------------------------------------------------------------- def test_get_config_unauthenticated_returns_401(client: TestClient) -> None: response = client.get("/api/config") assert response.status_code == 401 # --------------------------------------------------------------------------- # GET /api/config — authenticated # --------------------------------------------------------------------------- def test_get_config_authenticated_returns_sections(client: TestClient) -> None: _login(client) response = client.get("/api/config") assert response.status_code == 200 body = response.json() assert "sections" in body assert isinstance(body["sections"], list) assert len(body["sections"]) > 0 def test_get_config_sections_have_expected_structure(client: TestClient) -> None: _login(client) response = client.get("/api/config") body = response.json() for section in body["sections"]: assert "name" in section assert "fields" in section assert isinstance(section["fields"], list) for field in section["fields"]: assert "env_name" in field assert "label" in field assert "value" in field assert "secret" in field assert "input_type" in field assert "configured" in field def test_get_config_secret_fields_have_empty_string_value(client: TestClient) -> None: _login(client) response = client.get("/api/config") body = response.json() for section in body["sections"]: for field in section["fields"]: if field["secret"]: assert field["value"] == "", ( f"Secret field {field['env_name']} should be masked (empty string), " f"got {field['value']!r}" ) def test_get_config_includes_known_sections(client: TestClient) -> None: _login(client) response = client.get("/api/config") body = response.json() section_names = {s["name"] for s in body["sections"]} assert "System" in section_names assert "SMTP" in section_names assert "Authentication" in section_names # --------------------------------------------------------------------------- # PUT /api/config — unauthenticated # --------------------------------------------------------------------------- def test_put_config_unauthenticated_returns_401(client: TestClient) -> None: response = client.put( "/api/config", json={"updates": _full_config_payload()}, headers={"X-CSRF-Token": "any-token"}, ) assert response.status_code == 401 # --------------------------------------------------------------------------- # PUT /api/config — authenticated but missing CSRF # --------------------------------------------------------------------------- def test_put_config_authenticated_missing_csrf_returns_403(client: TestClient) -> None: _login(client) # No X-CSRF-Token header at all response = client.put( "/api/config", json={"updates": _full_config_payload()}, ) assert response.status_code == 403 def test_put_config_authenticated_empty_csrf_returns_403(client: TestClient) -> None: _login(client) # Empty string X-CSRF-Token header counts as missing response = client.put( "/api/config", json={"updates": _full_config_payload()}, headers={"X-CSRF-Token": ""}, ) assert response.status_code == 403 # --------------------------------------------------------------------------- # PUT /api/config — authenticated + CSRF present (any non-empty value) # --------------------------------------------------------------------------- def test_put_config_with_csrf_header_updates_app_name( client: TestClient, test_database_urls ) -> None: _login(client) payload = _full_config_payload({"APP_NAME": "Updated via API"}) response = client.put( "/api/config", json={"updates": payload}, headers={"X-CSRF-Token": "any-non-empty-value"}, ) assert response.status_code == 200 body = response.json() assert "sections" in body # The refreshed config in the response should reflect the new name system_section = next(s for s in body["sections"] if s["name"] == "System") app_name_field = next(f for f in system_section["fields"] if f["env_name"] == "APP_NAME") assert app_name_field["value"] == "Updated via API" def test_put_config_reapplies_dsmr_subscription( client: TestClient, test_database_urls ) -> None: """Saving config must re-apply the DSMR subscription so enabling DSMR ingest takes effect without an app restart (the route calls apply_dsmr_subscription with the refreshed settings).""" _login(client) payload = _full_config_payload({"DSMR_INGEST_ENABLED": "true"}) with patch("app.services.dsmr_ingest.apply_dsmr_subscription") as spy: response = client.put( "/api/config", json={"updates": payload}, headers={"X-CSRF-Token": "any-non-empty-value"}, ) assert response.status_code == 200 spy.assert_called_once() applied_settings = spy.call_args.args[0] assert applied_settings.dsmr_ingest_enabled is True def test_put_config_blank_secret_keeps_existing_value( client: TestClient, test_database_urls ) -> None: """Submitting a blank value for a secret field must NOT overwrite the stored secret.""" _login(client) # First: store a secret via a full PUT with the secret value set payload_with_secret = _full_config_payload({"SMTP_PASSWORD": "original-secret"}) 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}" # Second: PUT with blank for that secret (keep-old semantics) payload_blank_secret = _full_config_payload({"SMTP_PASSWORD": ""}) 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("SMTP_PASSWORD") == "original-secret" def test_put_config_returns_refreshed_sections(client: TestClient) -> None: _login(client) payload = _full_config_payload({"APP_NAME": "Refreshed Name"}) response = client.put( "/api/config", json={"updates": payload}, headers={"X-CSRF-Token": "token"}, ) assert response.status_code == 200 body = response.json() assert "sections" in body assert isinstance(body["sections"], list) assert len(body["sections"]) > 0 # Sections should reflect updated value system_section = next(s for s in body["sections"] if s["name"] == "System") app_name_field = next(f for f in system_section["fields"] if f["env_name"] == "APP_NAME") assert app_name_field["value"] == "Refreshed Name" def test_put_config_invalid_value_returns_422_and_does_not_write( client: TestClient, test_database_urls ) -> None: """An invalid config value (e.g. bad type for a typed field) must return 4xx and not persist.""" _login(client) # SMTP_PORT expects an integer; submit something that fails Settings validation payload = _full_config_payload({"SMTP_PORT": "not-a-number"}) response = client.put( "/api/config", json={"updates": payload}, headers={"X-CSRF-Token": "token"}, ) assert response.status_code == 422 # Confirm the bad value was not persisted 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("SMTP_PORT") != "not-a-number" # --------------------------------------------------------------------------- # Response schema correctness — secret values never leak in response # --------------------------------------------------------------------------- def test_put_config_response_does_not_leak_secret_values(client: TestClient) -> None: _login(client) # Set a secret payload1 = _full_config_payload({"HOME_ASSISTANT_AUTH_TOKEN": "super-secret-token"}) resp1 = client.put( "/api/config", json={"updates": payload1}, headers={"X-CSRF-Token": "token"}, ) assert resp1.status_code == 200 # Do another PUT and check response doesn't leak the secret payload2 = _full_config_payload({"APP_NAME": "check-secrets"}) response = client.put( "/api/config", json={"updates": payload2}, headers={"X-CSRF-Token": "token"}, ) assert response.status_code == 200 body = response.json() for section in body["sections"]: for field in section["fields"]: if field["secret"]: assert field["value"] == "", ( f"Secret field {field['env_name']} leaked in PUT response" ) # The secret value itself should not appear anywhere in the raw response assert "super-secret-token" not in str(body) # --------------------------------------------------------------------------- # POST /api/config/smtp/test — M2-T05 # --------------------------------------------------------------------------- _SMTP_TEST_URL = "/api/config/smtp/test" _SMTP_SEND_PATH = "app.api.routes.api.config.send_smtp_test_email" def test_smtp_test_unauthenticated_returns_401(client: TestClient) -> None: """Unauthenticated request must return 401 (require_session fires before require_csrf).""" response = client.post( _SMTP_TEST_URL, headers={"X-CSRF-Token": "any-token"}, ) assert response.status_code == 401 def test_smtp_test_authenticated_missing_csrf_returns_403(client: TestClient) -> None: """Authenticated but no X-CSRF-Token header must return 403.""" _login(client) response = client.post(_SMTP_TEST_URL) assert response.status_code == 403 def test_smtp_test_authenticated_empty_csrf_returns_403(client: TestClient) -> None: """Authenticated but empty X-CSRF-Token header must return 403.""" _login(client) response = client.post(_SMTP_TEST_URL, headers={"X-CSRF-Token": ""}) assert response.status_code == 403 def test_smtp_test_success_returns_200(client: TestClient) -> None: """When send_smtp_test_email succeeds (returns None), endpoint returns 200 with result=success.""" _login(client) with patch(_SMTP_SEND_PATH, return_value=None) as mock_send: response = client.post(_SMTP_TEST_URL, headers={"X-CSRF-Token": "any-token"}) mock_send.assert_called_once() assert response.status_code == 200 body = response.json() assert body["result"] == "success" assert "message" in body def test_smtp_test_config_error_returns_400(client: TestClient) -> None: """When send_smtp_test_email raises EmailConfigurationError, endpoint returns 400 with result=config-error.""" _login(client) with patch(_SMTP_SEND_PATH, side_effect=EmailConfigurationError("SMTP host is required")): response = client.post(_SMTP_TEST_URL, headers={"X-CSRF-Token": "any-token"}) assert response.status_code == 400 body = response.json() assert body["result"] == "config-error" assert "message" in body assert "SMTP host is required" in body["message"] def test_smtp_test_delivery_error_returns_502(client: TestClient) -> None: """When send_smtp_test_email raises EmailDeliveryError, endpoint returns 502 with result=failed.""" _login(client) with patch(_SMTP_SEND_PATH, side_effect=EmailDeliveryError("connection refused")): response = client.post(_SMTP_TEST_URL, headers={"X-CSRF-Token": "any-token"}) assert response.status_code == 502 body = response.json() assert body["result"] == "failed" assert "message" in body assert "connection refused" in body["message"] def test_smtp_test_response_does_not_echo_smtp_password(client: TestClient) -> None: """The SMTP password stored in config must not appear in any API response body.""" _login(client) smtp_password = "s3cr3t-smtp-pass" # Store a fake SMTP password in config payload = _full_config_payload({"SMTP_PASSWORD": smtp_password}) client.put( "/api/config", json={"updates": payload}, headers={"X-CSRF-Token": "token"}, ) # Simulate a delivery error whose message has already been sanitised by the # service layer (i.e. the password does not appear in the exception text). # This mirrors production behaviour: email.py's _sanitize_error_message # replaces any password occurrence with "[redacted]" before raising. with patch( _SMTP_SEND_PATH, side_effect=EmailDeliveryError("authentication failure: [redacted]"), ): response = client.post(_SMTP_TEST_URL, headers={"X-CSRF-Token": "token"}) assert response.status_code == 502 body = response.json() assert "result" in body assert "message" in body # The plaintext password must not appear anywhere in the response body assert smtp_password not in response.text # --------------------------------------------------------------------------- # M5-T08: MQTT / HA Discovery / Modbus CONFIG_FIELDS # --------------------------------------------------------------------------- def test_get_config_includes_mqtt_section(client: TestClient) -> None: """GET /api/config must include an MQTT section with expected fields.""" _login(client) response = client.get("/api/config") body = response.json() section_names = {s["name"] for s in body["sections"]} assert "MQTT" in section_names, f"MQTT section missing; got {section_names}" mqtt_section = next(s for s in body["sections"] if s["name"] == "MQTT") env_names = {f["env_name"] for f in mqtt_section["fields"]} assert "MQTT_ENABLED" in env_names assert "MQTT_BROKER_HOST" in env_names assert "MQTT_BROKER_PORT" in env_names assert "MQTT_USERNAME" in env_names assert "MQTT_PASSWORD" in env_names assert "MQTT_TLS_ENABLED" in env_names def test_get_config_includes_ha_discovery_section(client: TestClient) -> None: """GET /api/config must include a Home Assistant Discovery section.""" _login(client) response = client.get("/api/config") body = response.json() section_names = {s["name"] for s in body["sections"]} assert "Home Assistant Discovery" in section_names, ( f"Home Assistant Discovery section missing; got {section_names}" ) ha_section = next(s for s in body["sections"] if s["name"] == "Home Assistant Discovery") env_names = {f["env_name"] for f in ha_section["fields"]} assert "HA_DISCOVERY_ENABLED" in env_names assert "HA_DISCOVERY_PREFIX" in env_names def test_get_config_includes_modbus_section(client: TestClient) -> None: """GET /api/config must include a Modbus section with MODBUS_POLLING_ENABLED.""" _login(client) response = client.get("/api/config") body = response.json() section_names = {s["name"] for s in body["sections"]} assert "Modbus" in section_names, f"Modbus section missing; got {section_names}" modbus_section = next(s for s in body["sections"] if s["name"] == "Modbus") env_names = {f["env_name"] for f in modbus_section["fields"]} assert "MODBUS_POLLING_ENABLED" in env_names def test_get_config_mqtt_password_is_secret(client: TestClient) -> None: """MQTT_PASSWORD must be marked secret and return empty string in GET response.""" _login(client) response = client.get("/api/config") body = response.json() mqtt_section = next(s for s in body["sections"] if s["name"] == "MQTT") password_field = next(f for f in mqtt_section["fields"] if f["env_name"] == "MQTT_PASSWORD") assert password_field["secret"] is True assert password_field["value"] == "", ( f"MQTT_PASSWORD should be masked (empty string), got {password_field['value']!r}" ) def test_mqtt_broker_port_input_type_is_number(client: TestClient) -> None: """MQTT_BROKER_PORT must have input_type='number' for correct frontend rendering.""" _login(client) response = client.get("/api/config") body = response.json() mqtt_section = next(s for s in body["sections"] if s["name"] == "MQTT") port_field = next(f for f in mqtt_section["fields"] if f["env_name"] == "MQTT_BROKER_PORT") assert port_field["input_type"] == "number", ( f"MQTT_BROKER_PORT input_type should be 'number', got {port_field['input_type']!r}" ) def test_put_config_blank_mqtt_password_keeps_existing( client: TestClient, test_database_urls ) -> None: """Submitting blank MQTT_PASSWORD must NOT overwrite the stored secret.""" _login(client) # Store a secret via full PUT payload_with_secret = _full_config_payload({"MQTT_PASSWORD": "mqtt-secret-value"}) 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({"MQTT_PASSWORD": ""}) 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("MQTT_PASSWORD") == "mqtt-secret-value" def test_put_config_invalid_mqtt_port_returns_422_and_does_not_write( client: TestClient, test_database_urls ) -> None: """Non-numeric MQTT_BROKER_PORT must return 422 and not persist the bad value.""" _login(client) payload = _full_config_payload({"MQTT_BROKER_PORT": "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("MQTT_BROKER_PORT") != "not-a-number" # --------------------------------------------------------------------------- # M5-polish2 Area B: bool fields have input_type="checkbox" # --------------------------------------------------------------------------- EXPECTED_CHECKBOX_FIELDS = { "APP_DEBUG", "SMTP_ENABLED", "SMTP_USE_STARTTLS", "AUTH_LOGIN_THROTTLE_ENABLED", "MQTT_ENABLED", "MQTT_TLS_ENABLED", "HA_DISCOVERY_ENABLED", "MODBUS_POLLING_ENABLED", "DSMR_INGEST_ENABLED", } def test_bool_fields_have_checkbox_input_type(client: TestClient) -> None: """All pure-bool config fields must return input_type='checkbox' from GET /api/config.""" _login(client) response = client.get("/api/config") assert response.status_code == 200 body = response.json() all_fields = { field["env_name"]: field for section in body["sections"] for field in section["fields"] } for env_name in EXPECTED_CHECKBOX_FIELDS: assert env_name in all_fields, f"{env_name} not found in config response" actual_type = all_fields[env_name]["input_type"] assert actual_type == "checkbox", ( f"{env_name}: expected input_type='checkbox', got {actual_type!r}" ) def test_auth_cookie_secure_override_is_not_checkbox(client: TestClient) -> None: """AUTH_COOKIE_SECURE_OVERRIDE is bool|None (three-state) and must NOT be checkbox.""" _login(client) response = client.get("/api/config") assert response.status_code == 200 body = response.json() all_fields = { field["env_name"]: field for section in body["sections"] for field in section["fields"] } # This field is present in CONFIG_FIELDS; its type is bool|None so it stays text. if "AUTH_COOKIE_SECURE_OVERRIDE" in all_fields: actual_type = all_fields["AUTH_COOKIE_SECURE_OVERRIDE"]["input_type"] assert actual_type != "checkbox", ( f"AUTH_COOKIE_SECURE_OVERRIDE should not be checkbox (it is bool|None); got {actual_type!r}" ) def test_put_config_bool_field_roundtrip_true_false( client: TestClient, test_database_urls ) -> None: """Saving 'true'/'false' string for a bool checkbox field roundtrips correctly.""" _login(client) # Set MQTT_ENABLED to 'true' via config PUT payload_true = _full_config_payload({"MQTT_ENABLED": "true"}) resp_true = client.put( "/api/config", json={"updates": payload_true}, headers={"X-CSRF-Token": "token"}, ) assert resp_true.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("MQTT_ENABLED") == "true" # Now set to 'false' payload_false = _full_config_payload({"MQTT_ENABLED": "false"}) resp_false = client.put( "/api/config", json={"updates": payload_false}, headers={"X-CSRF-Token": "token"}, ) assert resp_false.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("MQTT_ENABLED") == "false" # --------------------------------------------------------------------------- # M5-fix2: DB-config → runtime settings (Issue 2 regression assertions) # --------------------------------------------------------------------------- def test_put_config_mqtt_reconnect_uses_db_merged_settings( client: TestClient, test_database_urls ) -> None: """PUT /api/config with MQTT keys must reconnect with DB-merged settings, not bootstrap-only settings. Asserts that mqtt_manager.reconnect receives settings where mqtt_enabled=True and mqtt_broker_host matches the submitted value. """ _login(client) target_host = "mqtt.example.com" payload = _full_config_payload({ "MQTT_ENABLED": "true", "MQTT_BROKER_HOST": target_host, }) mock_mgr = MagicMock() mock_mgr.is_configured.return_value = True with patch("app.api.routes.api.config.mqtt_manager", mock_mgr): resp = client.put( "/api/config", json={"updates": payload}, headers={"X-CSRF-Token": "token"}, ) assert resp.status_code == 200 # reconnect must have been called once assert mock_mgr.reconnect.call_count == 1, ( f"Expected mqtt_manager.reconnect called once, got {mock_mgr.reconnect.call_count}" ) reconnect_settings = mock_mgr.reconnect.call_args[0][0] assert reconnect_settings.mqtt_enabled is True, ( f"reconnect settings.mqtt_enabled must be True, got {reconnect_settings.mqtt_enabled!r}" ) assert reconnect_settings.mqtt_broker_host == target_host, ( f"reconnect settings.mqtt_broker_host must be {target_host!r}, " f"got {reconnect_settings.mqtt_broker_host!r}" ) # --------------------------------------------------------------------------- # 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: """POST /api/config/mqtt/test must use the DB-stored broker host, not env/bootstrap.""" _login(client) db_host = "broker.db-configured.local" # First write the MQTT config to DB via PUT /api/config payload_save = _full_config_payload({ "MQTT_ENABLED": "true", "MQTT_BROKER_HOST": db_host, }) with patch("app.api.routes.api.config.mqtt_manager"): save_resp = client.put( "/api/config", json={"updates": payload_save}, headers={"X-CSRF-Token": "token"}, ) assert save_resp.status_code == 200 # Now POST /api/config/mqtt/test — capture the host that _run_mqtt_test uses. captured_host: list[str] = [] def _fake_run_mqtt_test(settings): captured_host.append(settings.mqtt_broker_host) # simulate success by just returning return with patch("app.api.routes.api.config._run_mqtt_test", side_effect=_fake_run_mqtt_test): resp = client.post( "/api/config/mqtt/test", headers={"X-CSRF-Token": "token"}, ) assert resp.status_code == 200 assert len(captured_host) == 1, "Expected _run_mqtt_test to be called once" assert captured_host[0] == db_host, ( f"Expected mqtt test to use DB host {db_host!r}, got {captured_host[0]!r}" )