M8-R01: add configurable MQTT client identity

This commit is contained in:
2026-08-24 00:44:28 +02:00
parent 2a47dab272
commit e59c192097
12 changed files with 184 additions and 15 deletions
+39
View File
@@ -454,6 +454,7 @@ def test_get_config_includes_mqtt_section(client: TestClient) -> None:
assert "MQTT_USERNAME" in env_names
assert "MQTT_PASSWORD" in env_names
assert "MQTT_TLS_ENABLED" in env_names
assert "MQTT_CLIENT_ID" in env_names
def test_get_config_includes_ha_discovery_section(client: TestClient) -> None:
@@ -578,6 +579,18 @@ def test_put_config_invalid_mqtt_port_returns_422_and_does_not_write(
assert rows.get("MQTT_BROKER_PORT") != "not-a-number"
def test_put_config_invalid_mqtt_client_id_returns_422(client: TestClient) -> None:
_login(client)
response = client.put(
"/api/config",
json={"updates": _full_config_payload({"MQTT_CLIENT_ID": "invalid id"})},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 422
# ---------------------------------------------------------------------------
# M5-polish2 Area B: bool fields have input_type="checkbox"
# ---------------------------------------------------------------------------
@@ -722,6 +735,32 @@ def test_put_config_mqtt_reconnect_uses_db_merged_settings(
)
def test_put_config_mqtt_client_id_reconnects_and_trims_value(
client: TestClient, test_database_urls
) -> None:
_login(client)
mock_mgr = MagicMock()
with patch("app.api.routes.api.config.mqtt_manager", mock_mgr):
response = client.put(
"/api/config",
json={"updates": _full_config_payload({"MQTT_CLIENT_ID": " home-automation-dev "})},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 200
reconnect_settings = mock_mgr.reconnect.call_args.args[0]
assert reconnect_settings.mqtt_client_id == "home-automation-dev"
conn = sqlite3.connect(test_database_urls["app_path"])
try:
stored_value = conn.execute(
"SELECT value FROM app_config WHERE key = 'MQTT_CLIENT_ID'"
).fetchone()
finally:
conn.close()
assert stored_value == ("home-automation-dev",)
# ---------------------------------------------------------------------------
# M6-T02: DSMR + Tibber CONFIG_FIELDS
# ---------------------------------------------------------------------------
+1
View File
@@ -112,6 +112,7 @@ def test_app_start_seeds_missing_config_from_env_without_overwriting_existing_va
assert rows["APP_NAME"] == "Database Owned Name"
assert rows["HOME_ASSISTANT_BASE_URL"] == "http://bootstrap-ha.local:8123"
assert rows["AUTH_SESSION_COOKIE_NAME"] == "home_automation_session"
assert rows["MQTT_CLIENT_ID"] == "home-automation"
get_settings.cache_clear()
reset_db_caches()
+14
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import replace
from types import SimpleNamespace
import pytest
@@ -44,6 +45,7 @@ def fake_mqtt(monkeypatch):
fake = _FakeMqtt()
monkeypatch.setattr("app.integrations.mqtt.mqtt_manager", fake)
monkeypatch.setattr(dsmr_ingest, "_subscriptions", {})
monkeypatch.setattr(dsmr_ingest, "_subscription_client_ids", {})
monkeypatch.setattr(dsmr_ingest, "_tariffs", {})
return fake
@@ -81,6 +83,18 @@ def test_same_snapshot_has_no_subscription_churn(fake_mqtt, monkeypatch):
assert fake_mqtt.remove_calls == []
def test_changed_base_client_id_replaces_all_enabled_sources(fake_mqtt, monkeypatch):
snapshot = _source(1, "topic", broker_host="broker.test")
monkeypatch.setattr(dsmr_ingest, "_enabled_snapshots", lambda: [snapshot])
dsmr_ingest.apply_dsmr_subscription(SimpleNamespace(mqtt_client_id="home-automation"))
dsmr_ingest.apply_dsmr_subscription(SimpleNamespace(mqtt_client_id="home-automation-dev"))
assert fake_mqtt.remove_calls == [1]
assert [source_id for source_id, _ in fake_mqtt.replace_calls] == [1, 1]
assert fake_mqtt.replace_calls[-1][1]["base_client_id"] == "home-automation-dev"
def test_same_topic_on_different_brokers_is_allowed(fake_mqtt, monkeypatch):
monkeypatch.setattr(
dsmr_ingest,
+45 -1
View File
@@ -9,7 +9,7 @@ from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.integrations.mqtt import MqttManager
from app.integrations.mqtt import MqttManager, mqtt_source_client_id, mqtt_test_client_id
# ---------------------------------------------------------------------------
@@ -24,6 +24,7 @@ def _make_settings(
mqtt_username: str = "",
mqtt_password: str = "",
mqtt_tls_enabled: bool = False,
mqtt_client_id: str = "home-automation",
ha_discovery_prefix: str = "homeassistant",
):
"""Return a simple namespace that acts like a Settings object for MqttManager tests."""
@@ -34,6 +35,7 @@ def _make_settings(
s.mqtt_username = mqtt_username
s.mqtt_password = mqtt_password
s.mqtt_tls_enabled = mqtt_tls_enabled
s.mqtt_client_id = mqtt_client_id
s.ha_discovery_prefix = ha_discovery_prefix
return s
@@ -120,6 +122,34 @@ def test_connect_creates_paho_client_with_version2() -> None:
)
def test_connect_uses_configured_client_id() -> None:
manager = MqttManager()
settings = _make_settings(mqtt_client_id="home-automation-dev")
mock_client = MagicMock()
with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client) as mock_cls:
manager.connect(settings)
assert mock_cls.call_args.kwargs["client_id"] == "home-automation-dev"
def test_deployment_client_id_variants_are_distinct() -> None:
production_ids = {
"home-automation",
mqtt_source_client_id("home-automation", 7),
mqtt_test_client_id("home-automation"),
}
development_ids = {
"home-automation-dev",
mqtt_source_client_id("home-automation-dev", 7),
mqtt_test_client_id("home-automation-dev"),
}
assert len(production_ids) == 3
assert len(development_ids) == 3
assert production_ids.isdisjoint(development_ids)
def test_connect_sets_credentials_when_username_provided() -> None:
manager = MqttManager()
settings = _make_settings(mqtt_username="user", mqtt_password="s3cr3t")
@@ -431,3 +461,17 @@ def test_run_mqtt_test_raises_connection_error_on_timeout() -> None:
with patch("threading.Event", side_effect=_make_event):
with pytest.raises(_MqttConnectionError, match="timed out"):
_run_mqtt_test(settings)
def test_run_mqtt_test_uses_deployment_scoped_client_id() -> None:
from app.api.routes.api.config import _run_mqtt_test, _MqttConnectionError
settings = _make_settings(mqtt_client_id="home-automation-dev")
mock_client = MagicMock()
mock_client.connect.side_effect = OSError("Connection refused")
with patch("paho.mqtt.client.Client", return_value=mock_client) as mock_cls:
with pytest.raises(_MqttConnectionError):
_run_mqtt_test(settings)
assert mock_cls.call_args.kwargs["client_id"] == "home-automation-dev-test"
+20
View File
@@ -251,6 +251,7 @@ def test_replace_source_uses_isolated_client_and_source_credentials() -> None:
password="one-secret",
tls_enabled=True,
subscriptions={"one/topic": lambda payload: received.append(("one", payload))},
base_client_id="home-automation-dev",
)
manager.replace_source(
2,
@@ -284,6 +285,25 @@ def test_replace_source_uses_isolated_client_and_source_credentials() -> None:
assert received == [("two", b"two"), ("changed", b"changed")]
def test_source_client_id_is_scoped_by_deployment_and_source() -> None:
manager = MqttManager()
client = MagicMock()
with patch("app.integrations.mqtt.mqtt.Client", return_value=client) as mock_cls:
assert manager.replace_source(
42,
host="broker.test",
port=1883,
username="",
password="",
tls_enabled=False,
subscriptions={"topic": lambda _payload: None},
base_client_id="home-automation-dev",
)
assert mock_cls.call_args.kwargs["client_id"] == "home-automation-dev-dsmr-source-42"
def test_replaced_source_client_callbacks_cannot_reach_new_generation() -> None:
"""A retained old paho client cannot subscribe, mutate state, or dispatch new handlers."""
manager = MqttManager()