mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
feat(tools): MQTT bridge and Pixlet editor, ported onto the api_v3 split (#554)
* feat(tools): manage the MQTT bridge and Pixlet editor from the Tools tab PR #544's change, ported onto the api_v3 package split (#553). Identical behaviour; only the placement of the new code differs. The original added 508 lines to web_interface/blueprints/api_v3.py, which #553 deletes, so every hunk of it would conflict irreconcilably. Ported by AST: 26 new top-level items sorted to where the split puts each kind -- __init__.py 2 imports, 11 constants, 7 helpers starlark.py 4 routes (/starlark/editor/{apps,status,start,stop}) misc.py 2 routes (/integrations/mqtt-bridge{,/config}) Everything outside api_v3.py -- the Tools partial, the installer scripts, the JS tests -- applied unchanged. Routes: 111 from the split plus these 6 = 117, and the url-map snapshot is regenerated to match, which is exactly what test_api_v3_url_map.py is designed to make you do when routes are added. Full Python suite: 4,278 passed, 68 skipped, 0 failed. The JS tests this PR ships could not be run here -- node is not installed on this machine -- so test/js/dom/test_tools_sections.js is unverified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * fix(starlark): don't crash the pixlet editor's start/stop routes, and honor an operator-set PIXLET_EDITOR_HOST The AST-based port of #544 onto the api_v3 package split dropped `time` from starlark.py's import list. start_pixlet_editor() and stop_pixlet_editor() both call time.time()/time.sleep() directly, so every start (NameError building `state['started_at']`) and every stop that has to wait out the EXIT trap crashed with a 500. No test caught it because the route's own tests mock subprocess.Popen but never actually invoked it before now. Also carries over #544's later fix that this port branched before: env['PIXLET_EDITOR_HOST'] = '0.0.0.0' unconditionally overrode an operator who had already pinned PIXLET_EDITOR_HOST to loopback, forcing the unauthenticated `pixlet serve` process onto the LAN regardless (CodeQL CWE-1188). Switched to env.setdefault(...), same as api_v3.starlark.py's siblings already do for _pkg-owned names. Both fixes route the shared _pkg.time reference the rest of the package's route modules already use for anything a test might need to patch, rather than a bare `import time` local to this file. Ported the existing regression test from #544 (TestPixletEditorHostDefaultsButDoesNotOverride) onto this branch's module layout (web_interface.blueprints.api_v3.starlark instead of the old monolithic api_v3 module), which is what caught the NameError. Full suite: 4330 passed, 62 skipped, 2 failed -- identical on this branch and on origin/main (missing tzdata package breaks two timezone-alias tests in test_onboarding_checklist.py, unrelated to this change). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(api-v3): clear the six lint errors this rebase introduced All six were introduced by rebasing this branch onto the merged blueprint split, not by the split itself. Confirmed by diffing pyflakes output against main with line numbers normalised -- everything else it reports is present on main too and is the package's deliberate re-export pattern. starlark.py used _STARLARK_APPS_DIR three times without importing it (F821). The rebase resolved an import-list conflict as a union of both sides, and that symbol was on neither side of the conflict hunk, so it was silently lost. It is defined in __init__.py and is now imported like its neighbours. This was the only one of the six that would fail at runtime rather than merely lint. __init__.py imported contextlib twice (F811): the cherry-pick added one next to the existing import. Removed the duplicate; the original at line 19 is used. __init__.py imported signal purely to re-export it to starlark.py, so pyflakes saw it as unused (F401). signal is stdlib and does not need routing through the blueprint package, so starlark.py imports it directly and __init__.py no longer does. contextlib stays re-exported because this module genuinely uses it. _read_mqtt_bridge_config()'s local `config` shadowed the `config` submodule this module imports at the bottom for its route side effects (F811). Renamed to `settings`, with a comment saying why, since the name is otherwise the obvious one to reach for. Verified: pyflakes now reports nothing on this branch that main does not, the package imports, all nine route modules load, and 117 routes register, matching the pinned URL-map snapshot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(api-v3): reject MQTT bridge bodies the endpoint cannot apply Two CodeRabbit findings on the bridge settings endpoint, both of which returned 200 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 on the very next line. The guard was there to reject exactly those bodies. Dropping the `or {}` lets None fail it. The same `or {}` on /errors/clear is left alone: its docstring documents the body as optional, so an absent body legitimately means "use the defaults". The difference is that saving settings has nothing sensible to do with no body. `if data.get('clear_password'):` accepted any truthy value, and the string "false" is truthy in Python -- so a client echoing the field back as a string wiped a password it meant to keep. Now coerced through the package's existing _coerce_to_bool, which already maps 'true'/'on'/'1'/'yes' and nothing else. test_mqtt_bridge_config_endpoint.py covers both: five unusable body shapes plus a missing body, and clear_password across truthy and falsy spellings. Verified against the unfixed code -- reverting the body guard fails 5, reverting the coercion fails 3. Not changed here: CodeRabbit also asks this endpoint to reject MQTT credentials when TLS is off (CWE-319). That is a policy decision about the feature rather than a defect -- unencrypted MQTT on a trusted LAN is common and often deliberate -- so it is raised on the PR for a maintainer call instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: work through the remaining review findings on the editor and bridge allow_insecure_mqtt (CWE-319, requested): a password with TLS disabled crosses the network in cleartext. Refused now rather than merely warned about -- but refused, not forbidden, because unencrypted MQTT on a trusted LAN is a normal deliberate setup. allow_insecure_mqtt is the explicit acknowledgement, defaults false, and is coerced like the other booleans so the string "false" cannot switch the guard off. starlark.py:796 -- the supported service runs Flask threaded, so two start requests could each see running=False, each launch an editor, and the second state write replace the first PID, orphaning a process that holds the display down with nothing recording it. The check-launch-write sequence now takes a module-level lock. starlark.py:848 -- if the state write failed the route returned success with an editor running and no PID recorded: status and stop both reported no session while the display stayed down until the timeout expired. It now terminates the process group and returns an error. starlark.py:890 -- SIGKILL gives the script's EXIT trap no chance to run, so nothing hands the display back, yet the response said "the display is restarting". After an escalation the display is now restarted explicitly, and a failure to do so returns an error naming the manual step instead of a success. pixlet_config_editor.sh:184 -- find_pixlet supports Darwin but macOS ships no timeout(1); GNU coreutils installs it as gtimeout. Resolved up front so the failure lands before the display is stopped rather than after. pixlet_config_editor.sh:154 -- wildcard, loopback and an explicit interface address are three cases, not two. Collapsing the last two printed a URL saying "localhost" whenever PIXLET_EDITOR_HOST named a LAN address. tools.html:1254 -- escHtml does not encode single quotes, and the app id was interpolated into an inline onclick="startPixletEditor('...')", so a directory containing an apostrophe could break out of the JS string and run script. The handler binds with addEventListener and reads the id from dataset, where it is only ever parsed as an HTML attribute. Tests: test_mqtt_bridge_config_endpoint.py grows to 23 cases covering the opt-in in both directions. The tools DOM suite gains three guards asserting the edit buttons carry no inline onclick and pass the id via dataset -- those need jsdom and did not run here, so CI verifies them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(api-v3): log the traceback on the editor state-write failure The 848 fix answers 500 when the session state cannot be written, and logged that at error level -- but without exc_info, so the traceback never reached the log. test_web_error_detail.py guards exactly this: a handler returning 5xx must write an error-level record *with* the traceback and return the sanitized detail, because checking that merely something was logged is too weak. Caught by Core unit tests on the previous commit, not locally: the guard parses every module under web_interface/blueprints/api_v3 as one source, so it only fires once the whole package is read together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vendored
+51
@@ -315,6 +315,23 @@
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/integrations/mqtt-bridge",
|
||||
"api_v3.get_mqtt_bridge",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/integrations/mqtt-bridge/config",
|
||||
"api_v3.update_mqtt_bridge_config",
|
||||
[
|
||||
"OPTIONS",
|
||||
"PUT"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/logs",
|
||||
"api_v3.get_logs",
|
||||
@@ -747,6 +764,40 @@
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/editor/apps",
|
||||
"api_v3.list_pixlet_editor_apps",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/editor/start",
|
||||
"api_v3.start_pixlet_editor",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/editor/status",
|
||||
"api_v3.get_pixlet_editor_status",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/editor/stop",
|
||||
"api_v3.stop_pixlet_editor",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/install-pixlet",
|
||||
"api_v3.install_pixlet",
|
||||
|
||||
@@ -39,6 +39,7 @@ nothing is listening, so it stays useful in a bare checkout.
|
||||
| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup |
|
||||
| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry |
|
||||
| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` |
|
||||
| `dom/test_tools_sections.js` | yes | The Tools tab's MQTT bridge and Pixlet editor sections: form prefill, the write-only password (blank means unchanged), the running-session banner and countdown, and that the editor link points at the host you loaded the page from |
|
||||
|
||||
Point the DOM suites at a rig with a full plugin set when it matters — a dev box
|
||||
with two plugins installed will pass while exercising very little.
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Real-DOM (jsdom) test of the two new Tools sections. The HTML is the actual
|
||||
// server-rendered /partials/tools, and the payloads are the real API's, so a
|
||||
// renamed field or a changed shape fails this rather than passing quietly.
|
||||
const http = require('http');
|
||||
const { JSDOM, VirtualConsole } = require('jsdom');
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const get = p => new Promise((res, rej) =>
|
||||
http.get(BASE + p, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }).on('error', rej));
|
||||
|
||||
(async () => {
|
||||
const partial = await get('/partials/tools');
|
||||
const bridge = JSON.parse(await get('/api/v3/integrations/mqtt-bridge'));
|
||||
const apps = JSON.parse(await get('/api/v3/starlark/editor/apps'));
|
||||
|
||||
const errs = [];
|
||||
const vc = new VirtualConsole();
|
||||
vc.on('jsdomError', e => errs.push(String(e.message || e).split('\n')[0]));
|
||||
|
||||
// Controllable fetch: serve the real payloads, and let tests swap in others.
|
||||
let editorStatus = { status: 'success', data: { running: false } };
|
||||
let bridgePayload = bridge;
|
||||
let onPut = null;
|
||||
const stubFetch = (url, opts) => {
|
||||
const u = String(url);
|
||||
if (onPut && opts && opts.method === 'PUT') onPut(JSON.parse(opts.body));
|
||||
let body = { status: 'success', data: {} };
|
||||
if (u.includes('/integrations/mqtt-bridge')) body = bridgePayload;
|
||||
else if (u.includes('/starlark/editor/status')) body = editorStatus;
|
||||
else if (u.includes('/starlark/editor/apps')) body = apps;
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
|
||||
};
|
||||
|
||||
// runScripts:'dangerously' so the partial's own <script> executes the way a
|
||||
// browser runs it -- function declarations land on window. Evaluating the
|
||||
// source by hand instead leaves helpers like escHtml off the global object
|
||||
// and the page fails in ways it never would in a browser.
|
||||
const dom = new JSDOM(`<!doctype html><html><body>${partial}</body></html>`,
|
||||
{ runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/',
|
||||
beforeParse(w) { w.fetch = stubFetch; w.confirm = () => true; } });
|
||||
const { window } = dom;
|
||||
|
||||
const tick = ms => new Promise(r => setTimeout(r, ms));
|
||||
await tick(300);
|
||||
|
||||
const $ = id => window.document.getElementById(id);
|
||||
let pass = 0, fail = 0;
|
||||
const ok = (l, c, x) => c ? (pass++, console.log(' ok ' + l))
|
||||
: (fail++, console.log(' FAIL ' + l + (x !== undefined ? ' → ' + JSON.stringify(x).slice(0, 200) : '')));
|
||||
|
||||
console.log('\n── Tools: MQTT bridge + Pixlet editor (real DOM) ──');
|
||||
|
||||
// ── MQTT bridge ────────────────────────────────────────────────────────
|
||||
ok('bridge form rendered', !!$('mqtt-host'), $('mqtt-bridge-body').textContent.slice(0, 80));
|
||||
ok('host prefilled from the API', $('mqtt-host').value === bridge.data.config.mqtt_host,
|
||||
{ got: $('mqtt-host') && $('mqtt-host').value, want: bridge.data.config.mqtt_host });
|
||||
ok('port prefilled', $('mqtt-port').value === String(bridge.data.config.mqtt_port));
|
||||
ok('log level selected', $('mqtt-log-level').value === bridge.data.config.log_level);
|
||||
ok('TLS checkbox matches', $('mqtt-tls').checked === !!bridge.data.config.mqtt_tls);
|
||||
ok('password field is EMPTY', $('mqtt-password').value === '');
|
||||
ok('password field is type=password', $('mqtt-password').type === 'password');
|
||||
ok('no password value anywhere in the DOM',
|
||||
!/s3cret|mqtt_password"\s*:\s*"/.test(window.document.body.innerHTML));
|
||||
ok('state badge rendered', ($('mqtt-bridge-state').textContent || '').trim().length > 0,
|
||||
$('mqtt-bridge-state').textContent);
|
||||
ok('not-installed shows an Install button', !!$('btn-mqtt-install'));
|
||||
ok('config path shown', $('mqtt-bridge-body').textContent.includes('bridge_config.json'));
|
||||
ok('env override hint shown', $('mqtt-bridge-body').textContent.includes('LEDMATRIX_MQTT_'));
|
||||
|
||||
// The save body must omit the password when the field is blank.
|
||||
let sent = null;
|
||||
onPut = body => { sent = body; };
|
||||
window.saveMqttBridge();
|
||||
await tick(150);
|
||||
ok('save omits password when left blank', sent && !('mqtt_password' in sent), sent && Object.keys(sent));
|
||||
ok('save sends the edited fields', sent && sent.mqtt_host === bridge.data.config.mqtt_host, sent);
|
||||
|
||||
$('mqtt-password').value = 'typed-secret';
|
||||
window.saveMqttBridge();
|
||||
await tick(150);
|
||||
ok('save includes password once typed', sent && sent.mqtt_password === 'typed-secret');
|
||||
onPut = null;
|
||||
|
||||
// ── Pixlet editor, idle ────────────────────────────────────────────────
|
||||
const appIds = (apps.data.apps || []).map(a => a.id);
|
||||
ok('editor lists the apps on disk',
|
||||
appIds.every(id => $('pixlet-editor-body').textContent.includes(id)), appIds);
|
||||
ok('no session banner while idle', !$('pixlet-countdown'));
|
||||
// escHtml does not encode single quotes, so an app id interpolated into an
|
||||
// inline onclick="startPixletEditor('...')" could break out of the JS string
|
||||
// and run script. The id must reach the handler through dataset instead.
|
||||
const editBtns = [...window.document.querySelectorAll('[id^="btn-pixlet-edit-"]')];
|
||||
ok('edit buttons exist', editBtns.length > 0, editBtns.length);
|
||||
ok('edit buttons carry no inline onclick',
|
||||
editBtns.every(b => !b.getAttribute('onclick')),
|
||||
editBtns.map(b => b.getAttribute('onclick')));
|
||||
ok('edit buttons pass the app id via dataset',
|
||||
editBtns.every(b => appIds.includes(b.dataset.appId)),
|
||||
editBtns.map(b => b.dataset.appId));
|
||||
ok('warns that the display stops',
|
||||
window.document.body.textContent.includes('display stops while a session is open'));
|
||||
|
||||
// ── Pixlet editor, running ─────────────────────────────────────────────
|
||||
editorStatus = { status: 'success', data: {
|
||||
running: true, app_id: 'test-editor-app', port: 8099, seconds_remaining: 1634,
|
||||
timeout: 1800, host_bound: '0.0.0.0' } };
|
||||
window.loadPixletEditor();
|
||||
await tick(200);
|
||||
|
||||
ok('running session shows the banner', !!$('pixlet-countdown'));
|
||||
ok('countdown formatted mm:ss', $('pixlet-countdown').textContent === '27:14',
|
||||
$('pixlet-countdown') && $('pixlet-countdown').textContent);
|
||||
ok('Stop button offered', !!$('btn-pixlet-stop'));
|
||||
const link = [...window.document.querySelectorAll('#pixlet-editor-body a')].find(a => /Open the editor/.test(a.textContent));
|
||||
ok('editor link present', !!link);
|
||||
ok('link uses this host, not localhost — no tunnel needed',
|
||||
!!link && link.href.includes(window.location.hostname) && link.href.includes(':8099'),
|
||||
link && link.href);
|
||||
ok('Edit buttons disabled while a session runs',
|
||||
[...window.document.querySelectorAll('[id^="btn-pixlet-edit-"]')].every(b => b.disabled));
|
||||
ok('banner names the app being edited',
|
||||
$('pixlet-editor-body').textContent.includes('test-editor-app'));
|
||||
|
||||
ok('no uncaught JS errors', errs.length === 0, errs.slice(0, 3));
|
||||
console.log(`\n${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack.split('\n').slice(0, 5).join('\n')); process.exit(1); });
|
||||
+2
-1
@@ -15,7 +15,8 @@ const fs = require('fs');
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:5000';
|
||||
const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js'];
|
||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js'];
|
||||
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
|
||||
'dom/test_tools_sections.js'];
|
||||
|
||||
function reachable(url) {
|
||||
return new Promise(res => {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""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
|
||||
|
||||
@@ -906,3 +906,66 @@ class TestTheStoreUsesTheTokenTheUserConfigured:
|
||||
client.get('/api/v3/starlark/repository/browse')
|
||||
|
||||
repo.assert_called_once_with(github_token='ghp_configured')
|
||||
|
||||
|
||||
class TestPixletEditorHostDefaultsButDoesNotOverride:
|
||||
"""PIXLET_EDITOR_HOST must default to 0.0.0.0, never force it.
|
||||
|
||||
A browser reaching the editor is remote by definition, so a session with
|
||||
nothing configured has to bind more than loopback to be reachable at
|
||||
all -- but an operator who has deliberately pinned PIXLET_EDITOR_HOST to
|
||||
loopback (e.g. in the systemd unit's Environment=, to edit only over an
|
||||
SSH tunnel) must keep that setting. The previous unconditional
|
||||
``env['PIXLET_EDITOR_HOST'] = '0.0.0.0'`` overrode it every time,
|
||||
always exposing the unauthenticated ``pixlet serve`` dev process on the
|
||||
LAN regardless (CodeQL CWE-1188).
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def app_dir(self, tmp_path):
|
||||
d = tmp_path / "demo_app"
|
||||
d.mkdir()
|
||||
(d / "demo_app.star").write_text("def main():\n pass\n")
|
||||
return d
|
||||
|
||||
def _start(self, client, app_dir, tmp_path, operator_host):
|
||||
from web_interface.blueprints.api_v3 import starlark as mod
|
||||
|
||||
script = tmp_path / "pixlet_config_editor.sh"
|
||||
script.write_text("#!/bin/bash\n")
|
||||
state_file = tmp_path / "pixlet_editor_state.json"
|
||||
captured = {}
|
||||
|
||||
class FakeProcess:
|
||||
pid = 424242
|
||||
|
||||
def fake_popen(cmd, *args, env=None, **kwargs):
|
||||
if env is not None:
|
||||
captured['env'] = env
|
||||
return FakeProcess()
|
||||
|
||||
with patch.object(mod, '_validate_starlark_app_path',
|
||||
return_value=(app_dir, None)), \
|
||||
patch.object(mod, '_PIXLET_EDITOR_SCRIPT', script), \
|
||||
patch.object(mod, '_PIXLET_EDITOR_STATE', state_file), \
|
||||
patch.object(mod, '_find_pixlet_binary', return_value='/usr/bin/pixlet'), \
|
||||
patch.object(mod.subprocess, 'Popen', side_effect=fake_popen), \
|
||||
patch.dict(os.environ):
|
||||
if operator_host is None:
|
||||
os.environ.pop('PIXLET_EDITOR_HOST', None)
|
||||
else:
|
||||
os.environ['PIXLET_EDITOR_HOST'] = operator_host
|
||||
resp = client.post('/api/v3/starlark/editor/start',
|
||||
json={'app_id': app_dir.name})
|
||||
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert 'env' in captured, "subprocess.Popen was never called"
|
||||
return captured['env']
|
||||
|
||||
def test_defaults_to_0_0_0_0_when_operator_set_nothing(self, client, app_dir, tmp_path):
|
||||
env = self._start(client, app_dir, tmp_path, operator_host=None)
|
||||
assert env['PIXLET_EDITOR_HOST'] == '0.0.0.0'
|
||||
|
||||
def test_keeps_an_operator_configured_loopback_host(self, client, app_dir, tmp_path):
|
||||
env = self._start(client, app_dir, tmp_path, operator_host='127.0.0.1')
|
||||
assert env['PIXLET_EDITOR_HOST'] == '127.0.0.1'
|
||||
|
||||
Reference in New Issue
Block a user