Files
LEDMatrix/test/test_mqtt_bridge_config_endpoint.py
T
ChuckandClaude Opus 5.5 2236ff3081 fix(web-ui): MQTT password without TLS, Overview poll that never stopped, brightness slider error, token form left dirty (#745)
* fix(web-ui): let the MQTT bridge form save a password without TLS

PUT /api/v3/integrations/mqtt-bridge/config refuses a stored password
while mqtt_tls is off unless allow_insecure_mqtt is set (the CWE-319
guard in api_v3/misc.py). The Tools tab form neither rendered a control
for that flag nor sent it, so a password-protected broker on a LAN
without TLS could never be saved from the UI, and once such a password
was in bridge_config.json every later save from the form was refused.

The form now shows "Allow without TLS (trusted network)" while "Use
TLS" is unchecked, prefilled from the GET's config.allow_insecure_mqtt,
and mqttBody() sends its state as allow_insecure_mqtt. The box is off
until the user ticks it, so the server's guard still refuses a
cleartext password by default.

Tests: the Tools DOM suite checks the control, its show/hide with the
TLS box, the prefill and the value saved; a Flask test pins that the
GET reports the opt-in (false until saved on).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): stop the Overview reconciliation poll from running forever

The reconciliation banner script in partials/overview.html re-asked
/api/v3/plugins/reconciliation-status every 2 s until the answer said
done, with no limit. The route answers done: false whenever
ledmatrix_reconciliation.json is missing or unreadable, which happens
when _run_startup_reconciliation raises before writing it or when /tmp
is cleaned under a long-running web service (reconciliation runs once
per process). The browser then sent that request every 2 s for as long
as the page stayed open, on every tab, since the poll was never tied to
the Overview being visible.

The poll now gives up after 30 tries (a minute) and runs only while the
Overview is the active, visible tab, registered with LEDVisibility under
its own key like the other partials' pollers. Dismissing the banner
ends it too.

Test: test/js/unit/test_overview_reconciliation_poll.js runs the shipped
script in a vm with fake timers and fetch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): drop the Display tab's lookup of a removed brightness label

The brightness slider's input handler in partials/display.html set the
text of both #brightness-value and #brightness-display. #387
(978a03b42) removed the "LED brightness: N%" line that carried
#brightness-display, so getElementById returned null and every step of
the slider threw "Cannot set properties of null" into the console. The
visible label still updated, because it is written first.

The dead lookup is removed.

Test: test/js/unit/test_display_partial_ids.js checks every literal
getElementById() in the partial's inline scripts against the ids its
markup renders, and runs the shipped script in a vm to move the slider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): a created API token leaves the General tab's form clean

app.js marks a form data-dirty on any input inside it and removes the
mark only after a successful htmx request; its beforeunload handler
asks "Leave site?" while a visible form is still dirty. The API token
form in partials/general.html posts through window.webLogin.createToken
with fetch, so the mark survived the token being created and a reload
of the page with the General tab open prompted about a change that had
already been saved.

createToken now removes data-dirty after a successful create, next to
the form.reset() it already did. A refused request keeps the mark.

Test: test/js/unit/test_general_web_login_token.js runs the shipped
script in a vm with a fake fetch and DOM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(js): match <script> tags the way CodeQL's tag-filter rule expects

The three new suites pull the inline scripts out of their partials with
/<script>([\s\S]*?)<\/script>/g. CodeQL flags that shape as a bad HTML
filtering regexp (js/bad-tag-filter: misses upper case and tags with
attributes or whitespace), four high alerts that blocked the PR. These are
our own templates read by tests, not user input, but the stricter pattern
costs nothing: /<script\b[^>]*>(...)<\/script[^>]*>/gi, as
test_html_escaping.js already uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(js): slice the Display partial's markup around its scripts

CodeQL read the script-stripping replace() as an incomplete HTML sanitizer
(js/incomplete-multi-character-sanitization). The test only reads our own
template, but slicing between the matched blocks gives the same markup
without the pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:30:51 -04:00

158 lines
6.5 KiB
Python

"""The MQTT bridge settings endpoint must reject bodies it cannot apply.
Two defects CodeRabbit raised on #554, both of which reported success while
doing something other than what the caller asked:
* `request.get_json(silent=True) or {}` turned a missing or unparseable body --
and the JSON literals `null`, `[]` and `false` -- into an empty dict, which
then satisfied the `isinstance(data, dict)` guard directly below it. Malformed
JSON therefore returned 200 having applied nothing.
* `if data.get('clear_password'):` accepted any truthy value. The string
"false" is truthy in Python, so a client echoing the field back as a string
wiped a stored password it meant to keep.
"""
import json
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
import web_interface.blueprints.api_v3 as pkg # noqa: E402
import web_interface.blueprints.api_v3.misc as misc # noqa: E402
URL = "/api/v3/integrations/mqtt-bridge/config"
@pytest.fixture
def client(tmp_path, monkeypatch):
cfg = tmp_path / "bridge_config.json"
# Both modules: misc.py writes through its own imported copies of these
# names, while _read_mqtt_bridge_config() lives in the package __init__ and
# reads the one bound there. Patching only one leaves the read and the
# write pointing at different files.
for mod in (misc, pkg):
monkeypatch.setattr(mod, "_MQTT_BRIDGE_CONFIG", cfg, raising=False)
monkeypatch.setattr(mod, "_MQTT_BRIDGE_DIR", tmp_path, raising=False)
monkeypatch.setattr(api_v3, "config_manager", MagicMock(), raising=False)
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(api_v3, url_prefix="/api/v3")
app.cfg_path = cfg
return app.test_client(), cfg
class TestABodyItCannotApplyIsRejected:
@pytest.mark.parametrize("raw", ["{ not json", "null", "[]", "false", '"a string"'])
def test_unusable_bodies_are_rejected(self, client, raw):
c, _ = client
r = c.put(URL, data=raw, content_type="application/json")
# The regression: every one of these returned 200 having applied nothing.
assert r.status_code == 400, f"{raw!r} was accepted"
assert "JSON object" in r.get_json()["message"]
def test_a_missing_body_is_rejected(self, client):
c, _ = client
assert c.put(URL).status_code == 400
def test_a_real_object_is_still_accepted(self, client):
c, _ = client
r = c.put(URL, json={"mqtt_host": "192.168.1.20"})
assert r.status_code == 200, r.get_json()
class TestClearPasswordNeedsARealBoolean:
def _seed(self, cfg, password="hunter2"):
# TLS on so these cases exercise clear_password alone: a stored password
# with TLS off is refused by the CWE-319 guard, which is a separate test.
cfg.write_text(json.dumps({"mqtt_password": password, "mqtt_tls": True}),
encoding="utf-8")
def _stored(self, cfg):
return json.loads(cfg.read_text(encoding="utf-8")).get("mqtt_password")
def test_the_string_false_does_not_clear_it(self, client):
c, cfg = client
self._seed(cfg)
assert c.put(URL, json={"clear_password": "false"}).status_code == 200
# The regression: "false" is truthy, so this wiped the password.
assert self._stored(cfg) == "hunter2"
@pytest.mark.parametrize("falsy", [False, "no", "0", "", None])
def test_other_falsy_spellings_do_not_clear_it(self, client, falsy):
c, cfg = client
self._seed(cfg)
assert c.put(URL, json={"clear_password": falsy}).status_code == 200
assert self._stored(cfg) == "hunter2"
@pytest.mark.parametrize("truthy", [True, "true", "1", "yes", "on"])
def test_real_truthy_values_still_clear_it(self, client, truthy):
c, cfg = client
self._seed(cfg)
assert c.put(URL, json={"clear_password": truthy}).status_code == 200
assert self._stored(cfg) is None
class TestCleartextCredentialsNeedAnExplicitOptIn:
"""CWE-319. A password with TLS off crosses the network in the clear.
Refused rather than forbidden: unencrypted MQTT on a trusted LAN is a normal
deliberate setup, so allow_insecure_mqtt is the explicit acknowledgement.
"""
def test_a_password_without_tls_is_refused(self, client):
c, _ = client
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False})
assert r.status_code == 400
assert "allow_insecure_mqtt" in r.get_json()["message"]
def test_the_opt_in_allows_it(self, client):
c, cfg = client
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False,
"allow_insecure_mqtt": True})
assert r.status_code == 200, r.get_json()
assert json.loads(cfg.read_text(encoding="utf-8"))["mqtt_password"] == "hunter2"
def test_tls_on_needs_no_opt_in(self, client):
c, _ = client
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": True})
assert r.status_code == 200, r.get_json()
def test_no_password_is_unaffected(self, client):
c, _ = client
assert c.put(URL, json={"mqtt_tls": False}).status_code == 200
def test_the_opt_in_is_a_real_boolean(self, client):
""""false" must not switch the guard off, same as clear_password."""
c, _ = client
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False,
"allow_insecure_mqtt": "false"})
assert r.status_code == 400
def test_the_settings_read_reports_the_opt_in(self, client, monkeypatch):
"""The Tools form prefills its "Allow without TLS" box from the GET.
Off until someone saves it on, so an untouched form sends false and
the guard above still refuses a cleartext password.
"""
c, _ = client
monkeypatch.setattr(misc, "_mqtt_bridge_service_state",
lambda: {"installed": False, "active": False, "enabled": False})
def read():
return c.get("/api/v3/integrations/mqtt-bridge").get_json()["data"]["config"]
assert read()["allow_insecure_mqtt"] is False
r = c.put(URL, json={"mqtt_password": "hunter2", "mqtt_tls": False,
"allow_insecure_mqtt": True})
assert r.status_code == 200, r.get_json()
assert read()["allow_insecure_mqtt"] is True