mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* 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>
108 lines
4.0 KiB
JavaScript
108 lines
4.0 KiB
JavaScript
// Creating an API token on the General tab must leave its form clean.
|
|
//
|
|
// app.js marks a form data-dirty on any input in it and clears the mark only
|
|
// after a successful htmx request; its beforeunload handler then asks "Leave
|
|
// site?" while any visible form is still dirty. The token form posts with
|
|
// fetch (window.webLogin.createToken in partials/general.html), so after a
|
|
// token was created the form stayed dirty and reloading the page while the
|
|
// General tab was open prompted about changes that had been saved.
|
|
//
|
|
// Runs the shipped inline script in a vm with a fake fetch and DOM -- no jsdom
|
|
// and no server needed.
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
|
|
const PARTIAL = path.resolve(__dirname, '../../../web_interface/templates/v3/partials/general.html');
|
|
|
|
let pass = 0, fail = 0;
|
|
const ok = (label, cond, extra) => cond
|
|
? (pass++, console.log(' ok ' + label))
|
|
: (fail++, console.log(' FAIL ' + label + (extra !== undefined ? ' ' + JSON.stringify(extra) : '')));
|
|
|
|
function webLoginScript() {
|
|
const html = fs.readFileSync(PARTIAL, 'utf8');
|
|
const scripts = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi)].map(m => m[1]);
|
|
const found = scripts.find(s => s.includes('window.webLogin = {'));
|
|
if (!found) throw new Error('webLogin script not found in general.html');
|
|
return found;
|
|
}
|
|
|
|
function el() {
|
|
const classes = new Set(['hidden']);
|
|
return {
|
|
textContent: '', dataset: {}, style: {}, className: '',
|
|
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
|
|
appendChild() {}, addEventListener() {}, querySelector: () => null,
|
|
};
|
|
}
|
|
|
|
function load(answer) {
|
|
const elements = {
|
|
'web-login-tokens': el(),
|
|
'web-login-new-token-value': el(),
|
|
'web-login-new-token': el(),
|
|
};
|
|
const notes = [];
|
|
const window = { showNotification: (m, t) => notes.push([m, t]), alert() {}, confirm: () => true };
|
|
const context = {
|
|
window, console,
|
|
document: {
|
|
getElementById: id => elements[id] || null,
|
|
createElement: () => el(),
|
|
querySelectorAll: () => [],
|
|
},
|
|
fetch: () => Promise.resolve({
|
|
ok: answer.ok, status: answer.ok ? 200 : 400,
|
|
json: () => Promise.resolve(answer.body),
|
|
}),
|
|
};
|
|
vm.createContext(context);
|
|
vm.runInContext(webLoginScript(), context);
|
|
return { webLogin: context.window.webLogin, elements, notes };
|
|
}
|
|
|
|
function dirtyForm() {
|
|
const attrs = new Map([['data-dirty', '']]);
|
|
return {
|
|
querySelector: sel => (sel === '[name="name"]' ? { value: 'Home Assistant' } : null),
|
|
reset() {},
|
|
hasAttribute: name => attrs.has(name),
|
|
setAttribute: (name, value) => attrs.set(name, String(value)),
|
|
removeAttribute: name => attrs.delete(name),
|
|
};
|
|
}
|
|
|
|
const flush = async () => { for (let i = 0; i < 10; i++) await new Promise(r => setImmediate(r)); };
|
|
|
|
(async () => {
|
|
console.log('\n── General tab: API token form ──');
|
|
|
|
{
|
|
const t = load({ ok: true, body: {
|
|
status: 'success', message: 'Token created',
|
|
data: { token: 'lmx_secret', record: { id: 't1', name: 'Home Assistant', prefix: 'lmx_sec' } },
|
|
} });
|
|
const form = dirtyForm();
|
|
t.webLogin.createToken(form);
|
|
await flush();
|
|
ok('the new token is shown', t.elements['web-login-new-token-value'].textContent === 'lmx_secret');
|
|
ok('a created token leaves the form clean (no "Leave site?" on reload)',
|
|
!form.hasAttribute('data-dirty'));
|
|
}
|
|
|
|
{
|
|
const t = load({ ok: false, body: { status: 'error', message: 'Name is required' } });
|
|
const form = dirtyForm();
|
|
t.webLogin.createToken(form);
|
|
await flush();
|
|
ok('a refused request reports the error', t.notes.some(([m, type]) => type === 'error' && /Name is required/.test(m)),
|
|
t.notes);
|
|
ok('...and keeps the form dirty: nothing was saved', form.hasAttribute('data-dirty'));
|
|
}
|
|
|
|
console.log(`\n${pass} passed, ${fail} failed\n`);
|
|
process.exit(fail ? 1 : 0);
|
|
})().catch(e => { console.log('HARNESS ERROR: ' + e.stack); process.exit(1); });
|