Merge origin/main into claude/deprecate-unused-plugin-api

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-23 10:49:55 -04:00
co-authored by Claude Opus 5.5
28 changed files with 773 additions and 208 deletions
+3 -1
View File
@@ -15,7 +15,8 @@ npm install # jsdom, for the DOM suites only
node run_all.js
```
The unit suites need nothing but node. The DOM suites additionally need a
The unit suites need nothing but node; `test/test_js_unit_suites.py` runs every
`unit/*.js` under pytest, so CI covers them. The DOM suites additionally need a
running web interface, because they test against the **real** server-rendered
HTML and the **real** API rather than fixtures:
@@ -41,6 +42,7 @@ nothing is listening, so it stays useful in a bare checkout.
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
| `unit/test_style_editor_layout_leaf_collision.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only leaf key still gets its own column even when its name collides with an unrelated element's style sub-field or another layout axis's sub-field |
| `unit/test_inline_handler_escaping.js` | no | The store, saved-repository and custom-registry inline `onclick` handlers and the live `window.updateImageList` from `plugins_manager.js`: a registry id, URL or uploaded file name carrying `'`, `"` or entities adds no attributes and reaches the handler intact, and the store's View button opens only http(s) links |
| `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 |
| `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` |
+2 -1
View File
@@ -18,7 +18,8 @@ const UNIT = ['unit/test_list_filter.js', 'unit/test_render_cards.js',
'unit/test_html_escaping.js', 'unit/test_style_editor_element_keys.js',
'unit/test_style_editor_layout_leaf_columns.js',
'unit/test_style_editor_layout_leaf_collision.js',
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js'];
'unit/test_update_all.js', 'unit/test_inline_handler_escaping.js',
'unit/test_plugin_action_delegation.js'];
const DOM = ['dom/test_installed_dom.js', 'dom/test_store_dom.js', 'dom/test_no_double_fetch.js',
'dom/test_tools_sections.js'];
@@ -0,0 +1,127 @@
// Installed-plugin card actions go through handlePluginAction exactly once.
//
// The document-level delegation in plugins_manager.js tested
// `typeof handlePluginAction`, which lives inside the plugin-manager IIFE and
// so was never visible to it. Every click took a copied fallback instead,
// which stopped propagation (the grid's own listener never ran), asked to
// confirm an uninstall twice, and sent Starlark app uninstalls to the plugin
// endpoint instead of DELETE /starlark/apps/<id>.
//
// Runs the shipped global delegation and handlePluginAction, sliced out of
// plugins_manager.js, against a minimal fake DOM.
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const SRC = fs.readFileSync(
path.resolve(__dirname, '../../../web_interface/static/v3/plugins_manager.js'), 'utf8');
function slice(startMarker, endMarker) {
const a = SRC.indexOf(startMarker);
const b = SRC.indexOf(endMarker, a);
if (a < 0 || b < 0) throw new Error(`could not find ${startMarker} .. ${endMarker}`);
return SRC.slice(a, b);
}
const GLOBAL_DELEGATION = slice('(function setupGlobalEventDelegation() {', '// Note: configurePlugin');
const HANDLER = slice('function handlePluginAction(event) {', 'function findInstalledPlugin(pluginId)');
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 setup() {
const listeners = {};
const calls = { confirm: 0, fetch: [], uninstallPlugin: [], togglePlugin: [] };
const window = {
installedPlugins: [{ id: 'clock', enabled: false }],
uninstallPlugin: id => calls.uninstallPlugin.push(id),
togglePlugin: (id, on) => calls.togglePlugin.push([id, on]),
};
const ctx = {
window,
document: {
addEventListener: (type, fn, capture) => { (listeners[type] = listeners[type] || []).push(fn); },
},
confirm: () => { calls.confirm++; return true; },
fetch: (url, opts) => { calls.fetch.push([url, opts && opts.method]); return new Promise(() => {}); },
alert: () => {},
console,
setTimeout,
debugLog: () => {},
getInstalledFilter: () => null,
};
vm.createContext(ctx);
// The handler lives inside the plugin-manager IIFE in the real file, so it
// runs in one here too: the global delegation must not see it by name.
vm.runInContext(GLOBAL_DELEGATION + '\n(function() {\n' + HANDLER + '\n})();', ctx);
const click = (action, pluginId) => {
const el = {
getAttribute: name => ({ 'data-action': action, 'data-plugin-id': pluginId })[name],
type: 'button',
};
let stopped = false;
const event = {
type: 'click',
target: { closest: () => el },
preventDefault() {},
stopPropagation() { stopped = true; },
};
for (const fn of listeners.click || []) fn(event);
return stopped;
};
return { window, calls, click, listeners };
}
(async () => {
console.log('\n-- plugin card action delegation --');
{
const t = setup();
ok('handlePluginAction is exposed on window', typeof t.window.handlePluginAction === 'function');
ok('document-level click listener registered', (t.listeners.click || []).length === 1);
}
{
// A non-Element target (a text node, the document) has no closest().
const t = setup();
let threw = null;
try { for (const fn of t.listeners.click) fn({ type: 'click', target: {} }); } catch (e) { threw = e; }
ok('non-Element event target is ignored', threw === null, threw && threw.message);
}
{
const t = setup();
t.click('uninstall', 'starlark:analogclock');
ok('Starlark uninstall confirms once', t.calls.confirm === 1, t.calls.confirm);
ok('Starlark uninstall hits DELETE /starlark/apps/<id>',
t.calls.fetch.length === 1 && t.calls.fetch[0][0] === '/api/v3/starlark/apps/analogclock'
&& t.calls.fetch[0][1] === 'DELETE', t.calls.fetch);
ok('Starlark uninstall does not go to the plugin uninstaller', t.calls.uninstallPlugin.length === 0);
}
{
const t = setup();
t.click('uninstall', 'clock');
await new Promise(r => setTimeout(r, 20));
ok('plugin uninstall: handler itself does not confirm (uninstallPlugin does)', t.calls.confirm === 0,
t.calls.confirm);
ok('plugin uninstall calls uninstallPlugin once', t.calls.uninstallPlugin.length === 1
&& t.calls.uninstallPlugin[0] === 'clock', t.calls.uninstallPlugin);
}
{
const t = setup();
const stopped = t.click('toggle', 'clock');
await new Promise(r => setTimeout(r, 20));
ok('toggle flips the stored state', JSON.stringify(t.calls.togglePlugin) === '[["clock",true]]',
t.calls.togglePlugin);
ok('handled once (propagation stopped)', stopped === true);
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
})();
+13
View File
@@ -210,6 +210,19 @@ class TestVegasCycleDurations:
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['display_durations']['clock_duration'] == 45
def test_per_mode_duration_saves_and_blank_clears_it(self, api_v3_client, saved, api_v3_module):
# The Rotation page leaves a mode blank to mean "the plugin's own
# duration"; a saved value overrides the plugin, so blank must remove
# it rather than 400 or pin a number.
stored = copy.deepcopy(STORED)
stored['display']['display_durations'] = {'weather_current': 40, 'clock': 20}
api_v3_module.api_v3.config_manager.load_config.side_effect = lambda *a, **k: copy.deepcopy(stored)
resp = _post_json(api_v3_client, {'__form_section': 'durations',
'duration__clock': '45',
'duration__weather_current': ''})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['display_durations'] == {'clock': 45}
class TestRawSaveStartsAutoUpdateSetup:
@pytest.fixture
+29
View File
@@ -0,0 +1,29 @@
"""POST /plugins/toggle reports what actually went wrong.
Every failure used to be mapped to PLUGIN_OPERATION_CONFLICT, so the user was
told "A plugin operation is already in progress" when, say, the config could
not be read.
"""
import json
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
def test_failure_is_not_reported_as_a_conflict(api_v3_client, api_v3_module, monkeypatch):
monkeypatch.setattr(api_v3_module, '_discovered_plugin_manifests',
lambda *a, **k: {'clock': {}})
api_v3_module.api_v3.config_manager.load_config.side_effect = OSError('disk gone')
resp = api_v3_client.post('/api/v3/plugins/toggle',
data=json.dumps({'plugin_id': 'clock', 'enabled': True}),
content_type='application/json')
assert resp.status_code == 500
body = resp.get_json()
assert body['error_code'] != 'PLUGIN_OPERATION_CONFLICT'
assert 'already in progress' not in body['message']
assert body['message'] == 'Failed to enable plugin clock'
history = api_v3_module.api_v3.operation_history.record_operation
history.assert_called_once()
assert history.call_args.kwargs['plugin_id'] == 'clock'
@@ -110,12 +110,14 @@ class TestCachedConfigValues:
assert controller._normal_brightness == expected
def test_scroll_speed_cached(self, controller):
"""_scroll_speed must equal what the config says."""
"""_scroll_speed must equal what the config says, defaulting to Vegas's
own default so a follower dead-reckons at the leader's speed."""
from src.vegas_mode.config import VegasModeConfig
expected = (
controller.config
.get("display", {})
.get("vegas_scroll", {})
.get("scroll_speed", 75)
.get("scroll_speed", VegasModeConfig.from_config({}).scroll_speed)
)
assert controller._scroll_speed == expected
+121
View File
@@ -0,0 +1,121 @@
"""DisplayController settings that the web UI saves but the display ignored.
- Rotation & Durations: display.display_durations was never read, because
every plugin inherits get_display_duration() and the plugin was asked first.
- WiFi status overlay: the controller looked for wifi_status.json one level
above the repo, so WiFiManager's messages never reached the panel.
- Vegas: settings saved in the web UI never reached the running coordinator,
and the follower's scroll-speed default (75) disagreed with Vegas's (50).
"""
import os
import threading
from unittest.mock import MagicMock
os.environ.setdefault("EMULATOR", "true")
from src.display_controller import DisplayController
def _controller(config=None, plugin_modes=None):
dc = object.__new__(DisplayController)
dc.config = config or {}
dc.plugin_modes = plugin_modes or {}
return dc
def _plugin(duration):
plugin = MagicMock()
plugin.get_display_duration.return_value = duration
return plugin
class TestDisplayDuration:
def test_saved_duration_overrides_the_plugin(self):
dc = _controller({'display': {'display_durations': {'clock': 45}}},
{'clock': _plugin(15.0)})
assert dc._get_display_duration('clock') == 45.0
def test_unsaved_mode_uses_the_plugin_duration(self):
dc = _controller({'display': {'display_durations': {'other': 45}}},
{'clock': _plugin(12.0)})
assert dc._get_display_duration('clock') == 12.0
def test_invalid_saved_values_fall_back_to_the_plugin(self):
for bad in (0, -5, True, '30', None):
dc = _controller({'display': {'display_durations': {'clock': bad}}},
{'clock': _plugin(12.0)})
assert dc._get_display_duration('clock') == 12.0, bad
def test_unknown_mode_without_a_plugin_gets_the_default(self):
assert _controller()._get_display_duration('nothing') == 30
def test_hot_reloaded_config_is_used(self):
dc = _controller({}, {'clock': _plugin(15.0)})
dc._refresh_config_cache({'display': {'display_durations': {'clock': 60}}})
assert dc._get_display_duration('clock') == 60.0
class TestWifiStatusPath:
def test_reader_and_writer_agree(self, test_display_controller):
from src.wifi_manager import get_wifi_status_path
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
path = get_wifi_status_path()
assert path.name == 'wifi_status.json'
# Inside this checkout's config/, not the directory above the repo.
assert os.path.normcase(str(path.parent.parent)) == os.path.normcase(repo)
assert test_display_controller.wifi_status_file == path
def test_status_message_is_written_whole(self, tmp_path, monkeypatch):
import json
import src.wifi_manager as wm
target = tmp_path / 'wifi_status.json'
monkeypatch.setattr(wm, 'LED_STATUS_FILE', target)
# The display polls this file and deletes one it can't parse, so it
# must appear by rename, never be seen half-written.
renames = []
real_replace = os.replace
monkeypatch.setattr(wm.os, 'replace',
lambda src, dst: (renames.append(dst), real_replace(src, dst)))
manager = object.__new__(wm.WiFiManager)
manager._show_led_message('Connecting to home...', duration=10)
assert json.loads(target.read_text())['message'] == 'Connecting to home...'
assert renames == [target]
assert not (tmp_path / 'wifi_status.json.tmp').exists()
class TestVegasSettings:
def _controller(self):
dc = _controller({'display': {'vegas_scroll': {'scroll_speed': 40}}})
dc._reconcile_flag_lock = threading.Lock()
dc._pending_plugin_reconcile = False
dc._enabled_set_changed = lambda old, new: False
dc._enabled_plugin_not_running = lambda new: False
dc.vegas_coordinator = MagicMock()
return dc
def test_changed_vegas_settings_reach_the_coordinator(self):
dc = self._controller()
old = {'display': {'vegas_scroll': {'scroll_speed': 40}}}
new = {'display': {'vegas_scroll': {'scroll_speed': 80}}}
dc._controller_config_change(old, new)
dc.vegas_coordinator.update_config.assert_called_once_with(new)
assert dc._scroll_speed == 80.0
def test_unrelated_saves_leave_vegas_alone(self):
# Applying a Vegas config rebuilds the strip, so only do it on change.
dc = self._controller()
old = {'display': {'vegas_scroll': {'scroll_speed': 40}, 'hardware': {'brightness': 50}}}
new = {'display': {'vegas_scroll': {'scroll_speed': 40}, 'hardware': {'brightness': 90}}}
dc._controller_config_change(old, new)
dc.vegas_coordinator.update_config.assert_not_called()
def test_scroll_speed_default_matches_vegas_config(self):
from src.vegas_mode.config import VegasModeConfig
assert DisplayController._vegas_scroll_speed({}) == VegasModeConfig.from_config({}).scroll_speed
def test_stopped_coordinator_applies_queued_config(self):
dc = self._controller()
dc.on_demand_active = False
dc._is_vegas_mode_active()
dc.vegas_coordinator.apply_pending_config_if_idle.assert_called_once()
+10
View File
@@ -154,6 +154,16 @@ class TestPatternDetection:
assert "ValueError" not in aggregator._patterns
def test_affected_plugins_stays_bounded(self):
"""Each repeat used to append every plugin in the window again, so a
plugin failing in a loop grew this list without limit (3,000 errors
from three plugins reached 2.5 million entries)."""
aggregator = ErrorAggregator(pattern_threshold=2, pattern_window_minutes=60)
for i in range(300):
aggregator.record_error(error=ValueError("loop"), plugin_id=f"p{i % 3}")
assert aggregator._patterns["ValueError"].affected_plugins == ["p0", "p1", "p2"]
assert aggregator._patterns["ValueError"].to_dict()["affected_plugins"] == ["p0", "p1", "p2"]
def test_pattern_severity_increases_with_count(self):
"""Pattern severity should increase with more occurrences."""
aggregator = ErrorAggregator(
+8
View File
@@ -45,6 +45,14 @@ class TestGetFont:
font = fm.get_font("five_by_seven", 7)
assert isinstance(font, freetype.Face)
def test_bdf_at_a_size_it_lacks_uses_its_native_strike(self, fm):
# FreeType rejects any size but the strike's own. This used to hand
# back PIL's default font, a different typeface, for 5x7 at 8 or 10.
for size in (8, 10):
font = fm.get_font("five_by_seven", size)
assert isinstance(font, freetype.Face), size
assert font.size.y_ppem == 7
def test_repeat_call_returns_cached_identity(self, fm):
first = fm.get_font("press_start", 8)
hits_before = fm.performance_stats["cache_hits"]
+27
View File
@@ -0,0 +1,27 @@
"""Run every web-interface JS unit suite (test/js/unit/*.js) under pytest.
They need nothing but node, but CI ran only one of them, from
test/web_interface/test_update_all_plugins.py. The DOM suites need jsdom and
a running server, so they stay with test/js/run_all.js.
"""
import shutil
import subprocess
from pathlib import Path
import pytest
UNIT_DIR = Path(__file__).resolve().parent / 'js' / 'unit'
SUITES = sorted(UNIT_DIR.glob('test_*.js'))
def test_suites_found():
assert SUITES, f"no JS unit suites under {UNIT_DIR}"
@pytest.mark.skipif(shutil.which('node') is None, reason='node is not installed')
@pytest.mark.parametrize('suite', SUITES, ids=[s.name for s in SUITES])
def test_js_unit_suite(suite):
result = subprocess.run([shutil.which('node'), str(suite)], capture_output=True,
text=True, timeout=120, cwd=str(UNIT_DIR.parent))
assert result.returncode == 0, result.stdout + result.stderr
+65
View File
@@ -0,0 +1,65 @@
"""retry() in scripts/install/one-shot-install.sh retries, and reports failure.
It used `if ! "$@"; then status=$?`, where $? is the status of the negation,
always 0: a failed command was never retried and retry() returned success,
so a failed `git clone` carried on until a later check noticed the missing
checkout. The apt steps now retry for real but stay non-fatal, as they
effectively were; a clone that keeps failing stops the install.
"""
import re
import subprocess
import sys
from pathlib import Path
import pytest
ONE_SHOT = Path(__file__).resolve().parent.parent / "scripts" / "install" / "one-shot-install.sh"
pytestmark = pytest.mark.skipif(
not sys.platform.startswith("linux"), reason="runs the installer's bash under Linux"
)
def _function(name):
text = ONE_SHOT.read_text(encoding="utf-8")
m = re.search(rf"^{name}\(\) \{{\n.*?^\}}\n", text, re.S | re.M)
assert m, f"{name}() not found in one-shot-install.sh"
return m.group(0)
def _run(snippet):
script = (
"set -Eeuo pipefail\n"
"trap 'echo ERR_TRAP_FIRED >&2; exit 99' ERR\n"
"print_error() { echo \"E: $*\" >&2; }\n"
"print_warning() { echo \"W: $*\" >&2; }\n"
"sleep() { :; }\n"
f"{_function('retry')}\n"
f"{snippet}\n"
)
return subprocess.run(["bash", "-c", script], capture_output=True, text=True)
def test_failure_is_retried_and_reported():
r = _run("n=0; f() { n=$((n+1)); return 7; }\n"
"if retry f; then echo OK; else echo \"FAILED $? after $n\"; fi")
assert r.stdout.strip() == "FAILED 7 after 3", r.stdout + r.stderr
def test_success_on_a_later_attempt():
r = _run("n=0; f() { n=$((n+1)); [ $n -ge 2 ]; }\n"
"retry f && echo \"OK after $n\"")
assert r.stdout.strip() == "OK after 2", r.stdout + r.stderr
def test_a_plain_call_that_keeps_failing_stops_the_script():
r = _run("retry false\necho SHOULD_NOT_RUN")
assert "SHOULD_NOT_RUN" not in r.stdout
assert "ERR_TRAP_FIRED" in r.stderr
def test_apt_steps_stay_non_fatal():
text = ONE_SHOT.read_text(encoding="utf-8")
for line in text.splitlines():
if re.search(r"\bretry (sudo )?apt-get ", line):
assert "||" in line, f"apt step would now abort the install: {line.strip()}"
+92
View File
@@ -0,0 +1,92 @@
"""VegasModeCoordinator: settings updates and the per-frame live check.
- Settings saved in the web UI are queued with update_config() and applied
between frames. run_frame() returns early once Vegas has stopped, so a
disable followed by a re-enable used to be queued forever.
- The live-priority scan asks every plugin mode whether it is live. It ran on
every frame (125fps); it now runs at most every _LIVE_PRIORITY_CHECK_INTERVAL.
"""
import threading
from unittest.mock import MagicMock
from src.vegas_mode import coordinator as coordinator_module
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.coordinator import VegasModeCoordinator
def _coordinator(active=True):
# Built without __init__ so no display, stream or render stack is needed.
c = VegasModeCoordinator.__new__(VegasModeCoordinator)
c.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {'enabled': True}}})
c.render_pipeline = MagicMock()
c.render_pipeline.has_deferred.return_value = False
c.render_pipeline.needs_extension.return_value = False
c.render_pipeline.is_cycle_complete.return_value = False
c.stream_manager = MagicMock()
c.plugin_adapter = MagicMock()
c.stats = {'cycles_completed': 0, 'config_updates': 0}
c._state_lock = threading.Lock()
c._is_active = active
c._is_paused = False
c._should_stop = False
c._pending_config_update = False
c._pending_config = None
c._config_version = 0
c._live_priority_check = None
c._live_priority_active = False
c._interrupt_check = None
c.sync_manager = None
return c
class TestConfigWhileStopped:
def test_queued_config_applies_while_stopped(self):
c = _coordinator(active=False)
c.update_config({'display': {'vegas_scroll': {'enabled': True, 'scroll_speed': 90}}})
c.start = MagicMock()
c.apply_pending_config_if_idle()
assert c.vegas_config.scroll_speed == 90
assert c._pending_config_update is False
def test_reenabling_after_a_disable_takes_effect(self):
c = _coordinator(active=False)
c.vegas_config = VegasModeConfig.from_config({}) # disabled
c.start = MagicMock()
c.update_config({'display': {'vegas_scroll': {'enabled': True}}})
c.apply_pending_config_if_idle()
assert c.is_enabled
c.start.assert_called_once()
def test_running_coordinator_waits_for_the_next_frame(self):
c = _coordinator(active=True)
c.update_config({'display': {'vegas_scroll': {'enabled': True, 'scroll_speed': 90}}})
c.apply_pending_config_if_idle()
assert c.vegas_config.scroll_speed != 90
assert c._pending_config_update is True
class TestLivePriorityThrottle:
def test_scan_runs_at_most_once_per_interval(self, monkeypatch):
now = [1000.0]
monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: now[0])
c = _coordinator()
c._live_priority_check = MagicMock(return_value=None)
for _ in range(10):
c.run_frame()
assert c._live_priority_check.call_count == 1
now[0] += coordinator_module._LIVE_PRIORITY_CHECK_INTERVAL
c.run_frame()
assert c._live_priority_check.call_count == 2
def test_live_content_still_pauses_vegas(self, monkeypatch):
monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: 1000.0)
c = _coordinator()
c._live_priority_check = MagicMock(return_value='nfl_live')
c.pause = MagicMock()
assert c.run_frame() is False
c.pause.assert_called_once()
c.render_pipeline.render_frame.assert_not_called()
+22
View File
@@ -186,6 +186,28 @@ def test_durations_page_groups_by_plugin(client):
assert "Other saved entries" in body
def test_durations_page_leaves_unsaved_modes_blank(client):
"""A saved duration overrides the plugin's own, so the page must not
pre-fill one for every mode: the first save would pin them all. Unsaved
modes are blank, with the plugin's duration as the placeholder."""
import copy
from web_interface.blueprints import pages_v3 as pv
config = copy.deepcopy(SMOKE_CONFIG)
config["clock"]["display_duration"] = 20
config["display"]["display_durations"] = {"weather_current": 40}
pv.pages_v3.config_manager.load_config.return_value = config
body = client.get("/partials/durations").get_data(as_text=True)
def field(mode):
start = body.index(f'id="duration__{mode}"')
return body[start:body.index(">", start)]
assert 'value=""' in field("clock") and 'placeholder="20"' in field("clock")
assert 'value="40"' in field("weather_current")
assert 'value=""' in field("weather_daily")
assert 'placeholder="15"' in field("weather_daily")
def test_display_advanced_section_contains_tuning_fields(client):
body = client.get("/partials/display").get_data(as_text=True)
adv = body.find('id="display-section-advanced-hardware"')
+15
View File
@@ -129,3 +129,18 @@ def test_every_granted_helper_is_hardened_in_first_time_install_after_chown():
assert loop.start() > project_chown, (
"helper hardening runs before Step 11's project-wide chown, which undoes it")
assert _granted_helpers() <= set(loop.group(1).split())
def test_no_grant_runs_a_file_the_web_user_can_edit():
"""Every project file granted as root must be a fix_perms helper, which
both installers chown root:root (checked above). Anything else under the
project root is owned by the user after Step 11's chown, so a NOPASSWD
rule for it lets the web user rewrite the file and run it as root. The
grants for display_controller.py, start_display.sh and stop_display.sh
were exactly that, and nothing ever ran them through sudo."""
for installer in (FIRST_TIME, CONFIGURE):
for _, command in _grants(installer):
for token in command.split():
if token.startswith("$PROJECT_ROOT/"):
assert token.startswith("$PROJECT_ROOT/scripts/fix_perms/"), (
f"{installer.name} grants root on a user-owned file: {command}")