Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,31 @@ policies are unchanged.
the panel went off at 23:00 or at 23:01 depending on when in the minute
that check ran. Windows that cross midnight and per-day schedules follow
the same rule, and so does the dim schedule.
- The MQTT bridge settings on the Tools tab can save a broker password with
TLS off. The server refuses that unless `allow_insecure_mqtt` is set, and
the form had no way to set it, so a password-protected broker on a home
network without TLS could not be saved from the web UI, and once such a
password was stored every later save failed too. While "Use TLS" is
unchecked the form now shows "Allow without TLS (trusted network)",
prefilled from the saved settings. It is off until ticked, so the server
still refuses a cleartext password by default.
- The Overview's plugin-config warning check stops polling. It asked
`/api/v3/plugins/reconciliation-status` every 2 s until startup
reconciliation reported done, and the route reports not done whenever its
status file is missing: reconciliation raised before writing it, or /tmp
was cleaned under a long-running web service. The page then sent that
request every 2 s for as long as it stayed open, whichever tab was showing.
It now gives up after a minute and only polls while the Overview is on
screen.
- Moving the Brightness slider on the Display tab no longer throws an error
in the browser console on every step. Its handler also updated a "LED
brightness" line that was removed from the page in #387; the lookup is
gone.
- Creating an API token on the General tab no longer leaves the page asking
"Leave site?" on reload. The unsaved-changes guard marks a form when you
type in it and clears the mark only after an htmx save, and the token form
saves with a plain request, so it stayed marked after the token was
created. It is cleared once the token is saved.
- An on-demand session that ends during scheduled-off hours, by expiring or
being stopped, blanks the panel within about a second. It used to stay on
until the next minute, because the once-a-minute schedule check had
Expand Down
3 changes: 3 additions & 0 deletions test/js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ server has none.
| `unit/test_store_registry_fields.js` | no | The store card's registry fields from `plugins_manager.js`: the commit that introduced the listed version (a hex SHA only, linked to that tree), the "Needs LEDMatrix X+" warning, a card from an older registry without either, and `isStorePluginInstalled` answering to `aliases` |
| `unit/test_page_registry.js` | no | The page lifecycle in `js/core/registry.js` (a minimal DOM shim): one `init` per `data-page` root, `destroy` and an aborted `ctx.signal` when htmx swaps it away, a vetoed swap keeps it, lazy page modules, a root removed without htmx swept on the next swap |
| `unit/test_core_modules.js` | no | `js/core/api.js` (JSON envelope, HTTP/`status: error`/network errors, abort passthrough, the #683 login redirect, same-server paths only) and `js/core/facade.js` (`window.LEDMatrix`, deprecated aliases) |
| `unit/test_overview_reconciliation_poll.js` | no | The Overview's reconciliation-banner poll from `partials/overview.html`, run in a vm: it gives up after a bounded number of requests when the status never says done, runs only while the Overview is on screen (`LEDVisibility`, its own key), and stops once the banner is shown |
| `unit/test_display_partial_ids.js` | no | `partials/display.html`: every literal `getElementById()` in its inline scripts names an id the partial renders, and moving the brightness slider (the shipped script, in a vm with a fake DOM) updates its label without throwing |
| `unit/test_general_web_login_token.js` | no | `window.webLogin.createToken` from `partials/general.html`, run in a vm: a created API token clears the form's `data-dirty` mark (so a reload does not ask "Leave site?"), a refused one keeps it |
| `unit/test_plugin_action_delegation.js` | no | The document-level card-action delegation and `handlePluginAction` from `plugins_manager.js`, run with the handler inside an IIFE as in the real file: each action is handled once, a Starlark app uninstall goes to `DELETE /starlark/apps/<id>`, and an uninstall is confirmed once |
| `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 |
Expand Down
42 changes: 42 additions & 0 deletions test/js/dom/test_tools_sections.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,50 @@ const get = p => new Promise((res, rej) =>
window.saveMqttBridge();
await tick(150);
ok('save includes password once typed', sent && sent.mqtt_password === 'typed-secret');

// A password with TLS off is refused unless allow_insecure_mqtt is set
// (CWE-319, api_v3/misc.py). The form has to be able to send it, or a
// plain-LAN broker with a password can never be saved from here.
const allowRow = () => $('mqtt-allow-insecure-row');
const shown = el => !!el && !el.classList.contains('hidden');
ok('allow-without-TLS control rendered', !!$('mqtt-allow-insecure'));
ok('allow-without-TLS starts as saved',
!!$('mqtt-allow-insecure') && $('mqtt-allow-insecure').checked === !!bridge.data.config.allow_insecure_mqtt);
ok('allow-without-TLS shown only while TLS is off',
shown(allowRow()) === !$('mqtt-tls').checked);
$('mqtt-tls').checked = true;
$('mqtt-tls').dispatchEvent(new window.Event('change', { bubbles: true }));
ok('ticking TLS hides it', !shown(allowRow()));
$('mqtt-tls').checked = false;
$('mqtt-tls').dispatchEvent(new window.Event('change', { bubbles: true }));
ok('unticking TLS shows it again', shown(allowRow()));

const setAllow = v => { if ($('mqtt-allow-insecure')) $('mqtt-allow-insecure').checked = v; };
setAllow(false);
window.saveMqttBridge();
await tick(150);
ok('save sends allow_insecure_mqtt false when unticked', !!sent && sent.allow_insecure_mqtt === false, sent);
setAllow(true);
window.saveMqttBridge();
await tick(150);
ok('save sends allow_insecure_mqtt true when ticked', !!sent && sent.allow_insecure_mqtt === true, sent);
onPut = null;

// Prefilled from the saved settings, and hidden while TLS is saved on.
bridgePayload = JSON.parse(JSON.stringify(bridge));
bridgePayload.data.config.allow_insecure_mqtt = true;
bridgePayload.data.config.mqtt_tls = false;
window.loadMqttBridge();
await tick(150);
ok('a saved opt-in is prefilled', !!$('mqtt-allow-insecure') && $('mqtt-allow-insecure').checked === true);
bridgePayload.data.config.mqtt_tls = true;
window.loadMqttBridge();
await tick(150);
ok('hidden on load when TLS is saved on', !shown(allowRow()));
bridgePayload = bridge;
window.loadMqttBridge();
await tick(150);

// ── Pixlet editor, idle ────────────────────────────────────────────────
const appIds = (apps.data.apps || []).map(a => a.id);
ok('editor lists the apps on disk',
Expand Down
5 changes: 4 additions & 1 deletion test/js/run_all.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js',
'unit/test_plugin_action_delegation.js', 'unit/test_file_upload_widget.js',
'unit/test_store_registry_fields.js', 'unit/test_restart_banner.js',
'unit/test_page_registry.js', 'unit/test_core_modules.js'];
'unit/test_page_registry.js', 'unit/test_core_modules.js',
'unit/test_overview_reconciliation_poll.js',
'unit/test_display_partial_ids.js',
'unit/test_general_web_login_token.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js', 'dom/test_cache_page.js',
'dom/test_durations_page.js', 'dom/test_operation_history_page.js',
Expand Down
108 changes: 108 additions & 0 deletions test/js/unit/test_display_partial_ids.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// The Display tab's inline script must only look up elements the partial
// renders.
//
// Its brightness slider handler also wrote to #brightness-display, a "LED
// brightness: N%" line that #387 removed from partials/display.html. The
// lookup returned null, so every movement of the slider threw a TypeError.
// This checks every literal getElementById() in the partial's inline scripts
// against the ids its markup renders, and runs the shipped script in a vm
// with a fake DOM (null for an id the markup lacks, as in a browser) to move
// the slider.
//
// 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/display.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) : '')));

const html = fs.readFileSync(PARTIAL, 'utf8');
const blocks = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi)];
const scripts = blocks.map(m => m[1]);
// The markup is what lies between the script blocks (sliced around them, not
// a replace(), which CodeQL reads as an incomplete HTML sanitizer).
let markup = '';
let from = 0;
for (const m of blocks) {
markup += html.slice(from, m.index);
from = m.index + m[0].length;
}
markup += html.slice(from);
const rendered = new Set([...markup.matchAll(/\bid="([^"{}]+)"/g)].map(m => m[1]));

console.log('\n── Display partial: element lookups ──');

// 1. Static: every literal lookup names an id the partial renders.
const lookups = scripts.flatMap(s => [...s.matchAll(/getElementById\('([^']+)'\)/g)].map(m => m[1]));
const missing = [...new Set(lookups.filter(id => !rendered.has(id)))];
ok('the inline scripts look elements up', lookups.length > 0, lookups.length);
ok('every looked-up id is rendered by the partial', missing.length === 0, missing);

// 2. Behaviour: moving the brightness slider updates its label and throws nothing.
function fakeElement(id) {
const listeners = {};
const classes = new Set();
return {
id, value: '', textContent: '', min: '', max: '', checked: false,
style: {}, dataset: {}, className: '',
classList: {
add: c => classes.add(c), remove: c => classes.delete(c),
toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : (on ? classes.add(c) : classes.delete(c))),
contains: c => classes.has(c),
},
addEventListener: (type, fn) => { (listeners[type] ||= []).push(fn); },
dispatchEvent() { return true; },
appendChild() {},
listeners,
};
}

const main = scripts.find(s => s.includes("getElementById('brightness')"));
ok('found the script that wires the brightness slider', !!main);
if (main) {
const elements = new Map();
const document = {
readyState: 'complete',
hidden: false,
getElementById: id => {
if (!rendered.has(id)) return null;
if (!elements.has(id)) elements.set(id, fakeElement(id));
return elements.get(id);
},
createElement: () => fakeElement(''),
createTextNode: () => ({}),
addEventListener() {},
};
const window = {
LEDEscape: { html: v => String(v), attr: v => String(v) },
LEDVisibility: { onActive() {} },
};
const context = {
window, document, console, URLSearchParams,
fetch: () => new Promise(() => {}),
setTimeout: () => 0, clearTimeout() {}, setInterval: () => 0, clearInterval() {},
};
vm.createContext(context);
let loadError = null;
try { vm.runInContext(main, context); } catch (e) { loadError = e; }
ok('the script loads', !loadError, loadError && String(loadError));

const slider = elements.get('brightness');
const handlers = (slider && slider.listeners.input) || [];
ok('the slider has an input handler', handlers.length > 0);
let thrown = null;
slider.value = '42';
try { handlers.forEach(fn => fn.call(slider, { target: slider })); } catch (e) { thrown = e; }
ok('moving the slider throws nothing', !thrown, thrown && String(thrown));
ok('...and shows the new value', elements.get('brightness-value').textContent === '42',
elements.get('brightness-value').textContent);
}

console.log(`\n${pass} passed, ${fail} failed\n`);
process.exit(fail ? 1 : 0);
107 changes: 107 additions & 0 deletions test/js/unit/test_general_web_login_token.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// 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); });
Loading
Loading