Files
LEDMatrix/test/test_auto_update_setup.py
ChuckandClaude Opus 5.5 f6c0fe55d9 fix(core): font zip cache, monotonic timers, resolver back-off, and other core/common fixes (#654)
* fix(core): font zip cache, monotonic timers, resolver back-off, and other core/common fixes

- font_manager: a .zip font URL is served as its extracted font after a
  restart (the cached-file check returned the archive first); downloads
  use requests with a 30s timeout into a temp file + os.replace.
- api_helper / sync_manager: rate-limit and heartbeat/leader timeouts use
  time.monotonic(); last_request_time and the status file's ts stay
  wall-clock. set_on_new_cycle docstring no longer claims core uses it.
- logo_helper: the placeholder uses the same scaled box as a real logo.
- permission_utils: one _sudo_bash_candidates() helper (with the sudoers
  exact-argv rationale) shared by sudo_remove_directory, which now retries
  the next bash path on a sudo refusal, and install_requirements_file.
- dynamic_team_resolver: failed/empty fetch backs off 5 min; duplicate
  INFO log and contradictory docstring example fixed.
- element_style: scale default looked up through element aliases.
- background_data_service: cache-hit callback runs outside the lock.
- config_arrays: union-aware type check (["array","null"]); stale
  dotToNested() reference removed.
- auto_update_setup: non-dict auto_update reads as off; temp result file
  unlinked when the write fails.
- exceptions: constructors copy the caller's context dict.
- logging_config: StructuredFormatter json.dumps(default=str).
- error_aggregator: removed unused export_path/export_to_file/_auto_export.
- Docstrings: validate_file_upload max_size_mb, raise_on_errors.

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

* fix(sync): retry the status-file rename like the other atomic writers

On Windows os.replace can fail with "Access is denied" while a scanner
briefly holds the target open; config_manager_atomic._replace already
retries that (and re-raises at once on other platforms). The sync status
writer called os.replace directly, which made
test_concurrent_writers_each_use_their_own_temp_file flaky on Windows.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:40:16 -04:00

234 lines
9.8 KiB
Python

"""Installing the automatic-update health check from the display service.
src/auto_update_setup.py is how a user who never opens a terminal gets updates
with a safety net: it runs as root inside the display service and writes
systemd units. So it has to install exactly the right thing when asked, do
nothing when not asked, refuse templates that would run as anyone but the web
user, and say why whenever it could not finish.
"""
import json
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from src import auto_update_setup as aus # noqa: E402
ON = {'auto_update': {'enabled': True}}
class FakeSystemctl:
def __init__(self, fail=(), activates=True):
self.calls = []
self.fail = set(fail)
self.activates = activates
self.active = False
def __call__(self, args, **kwargs):
assert args[0] == 'systemctl', args
self.calls.append(args[1:])
verb = args[1]
if verb == 'is-active':
return subprocess.CompletedProcess(args, 0 if self.active else 3,
stdout='active\n' if self.active else 'inactive\n', stderr='')
if verb in self.fail:
return subprocess.CompletedProcess(args, 1, stdout='', stderr=f'{verb} refused')
if verb in ('restart', 'enable') and self.activates and (verb == 'restart' or '--now' in args):
self.active = True
return subprocess.CompletedProcess(args, 0, stdout='', stderr='')
def project(tmp_path, user='hdpi', workdir=None, name='LEDMatrix'):
root = tmp_path / name
(root / 'systemd').mkdir(parents=True)
for name in aus.UNITS:
shutil.copy(ROOT / 'systemd' / name, root / 'systemd' / name)
etc = tmp_path / 'etc'
etc.mkdir()
(etc / aus.WEB_UNIT).write_text(
f'[Service]\nUser={user}\nWorkingDirectory={workdir or root}\n', encoding='utf-8')
return root, etc
def setup(root, etc, systemctl, root_user=True):
return aus.UpdateHelperSetup(project_root=root, systemd_dir=etc, run=systemctl,
is_root=lambda: root_user,
lookup_ids=lambda user: None if user == 'ghost' else (1000, 1000),
clock=lambda: 1234.0)
def result(root):
return json.loads((root / aus.RESULT_REL).read_text())
def test_does_nothing_while_updates_are_off(tmp_path):
root, etc = project(tmp_path)
systemctl = FakeSystemctl()
assert setup(root, etc, systemctl).ensure({'auto_update': {'enabled': False}}) is None
assert systemctl.calls == []
assert not (etc / aus.SERVICE_UNIT).exists()
assert not (root / aus.RESULT_REL).exists()
@pytest.mark.parametrize('value', [True, 'yes', ['enabled']])
def test_a_non_object_auto_update_value_reads_as_off(value):
"""A hand-edited "auto_update": true raised AttributeError and aborted
setup; the web UI's save treats a non-object as {} (off), so this does too."""
assert aus.is_enabled({'auto_update': value}) is False
def test_a_failed_result_write_leaves_no_temp_file(tmp_path, monkeypatch):
root, etc = project(tmp_path)
def broken_dump(*args, **kwargs):
raise OSError('disk full')
monkeypatch.setattr(aus.json, 'dump', broken_dump)
setup(root, etc, FakeSystemctl())._report('failed', 'x')
leftovers = [p.name for p in (root / aus.RESULT_REL).parent.iterdir()
if p.name.startswith('.auto_update_setup_')]
assert leftovers == []
def test_installs_both_units_for_the_web_user(tmp_path):
root, etc = project(tmp_path)
systemctl = FakeSystemctl()
out = setup(root, etc, systemctl).ensure(ON)
assert out['status'] == 'installed' and result(root)['status'] == 'installed'
service = (etc / aus.SERVICE_UNIT).read_text()
assert 'User=hdpi' in service
assert f'ExecStart=/usr/bin/python3 "{root}/data/auto_update_verifier.py" "{root}"' in service
assert f'PathExists={root}/data/auto_update_verify.request' in (etc / aus.PATH_UNIT).read_text()
assert '__' not in service
assert ['daemon-reload'] in systemctl.calls
assert ['enable', aus.PATH_UNIT] in systemctl.calls
assert systemctl.active
def test_second_start_changes_nothing(tmp_path):
root, etc = project(tmp_path)
systemctl = FakeSystemctl()
setup(root, etc, systemctl).ensure(ON)
first = result(root)
systemctl.calls.clear()
setup(root, etc, systemctl).ensure(ON)
assert systemctl.calls == [['is-active', aus.PATH_UNIT], ['is-active', aus.PATH_UNIT]]
assert result(root) == first, "an unchanged setup must not rewrite its result every boot"
def test_a_changed_template_is_reinstalled(tmp_path):
root, etc = project(tmp_path)
systemctl = FakeSystemctl()
setup(root, etc, systemctl).ensure(ON)
template = root / 'systemd' / aus.SERVICE_UNIT
template.write_text(template.read_text() + '\n# newer\n')
systemctl.calls.clear()
assert setup(root, etc, systemctl).ensure(ON)['message'] == 'Installed the update health check.'
assert (etc / aus.SERVICE_UNIT).read_text().endswith('# newer\n')
assert ['daemon-reload'] in systemctl.calls
def test_a_template_that_would_not_run_as_the_web_user_is_refused(tmp_path):
root, etc = project(tmp_path)
template = root / 'systemd' / aus.SERVICE_UNIT
template.write_text(template.read_text().replace('User=__USER__', 'User=root'))
systemctl = FakeSystemctl()
out = setup(root, etc, systemctl).ensure(ON)
assert out['status'] == 'failed' and 'refusing' in out['message']
assert not (etc / aus.SERVICE_UNIT).exists()
assert systemctl.calls == []
def test_a_path_unit_that_starts_something_else_is_refused(tmp_path):
root, etc = project(tmp_path)
template = root / 'systemd' / aus.PATH_UNIT
template.write_text(template.read_text().replace('Unit=ledmatrix-update-verify.service', 'Unit=other.service'))
out = setup(root, etc, FakeSystemctl()).ensure(ON)
assert out['status'] == 'failed' and not (etc / aus.PATH_UNIT).exists()
@pytest.mark.parametrize('case, expected', [
('not_root', 'not running as root'),
('no_web_unit', 'not installed'),
('other_folder', 'runs from'),
('bad_user', 'not a usable account'),
])
def test_reports_why_it_could_not_install(tmp_path, case, expected):
root, etc = project(tmp_path, user='ghost' if case == 'bad_user' else 'hdpi',
workdir=tmp_path / 'elsewhere' if case == 'other_folder' else None)
if case == 'no_web_unit':
(etc / aus.WEB_UNIT).unlink()
out = setup(root, etc, FakeSystemctl(), root_user=case != 'not_root').ensure(ON)
assert out['status'] == 'failed' and expected in out['message']
assert result(root)['message'] == out['message']
assert not (etc / aus.SERVICE_UNIT).exists()
def test_a_path_unit_that_will_not_start_is_a_failure(tmp_path):
root, etc = project(tmp_path)
out = setup(root, etc, FakeSystemctl(activates=False)).ensure(ON)
assert out['status'] == 'failed' and 'did not start' in out['message']
def test_systemctl_errors_are_reported(tmp_path):
root, etc = project(tmp_path)
out = setup(root, etc, FakeSystemctl(fail={'daemon-reload'})).ensure(ON)
assert out['status'] == 'failed' and 'daemon-reload refused' in out['message']
def test_not_a_systemd_host_is_left_alone(tmp_path):
root, _ = project(tmp_path)
systemctl = FakeSystemctl()
assert setup(root, tmp_path / 'no-etc', systemctl).ensure(ON) is None
assert systemctl.calls == []
def test_a_folder_with_spaces_is_quoted_in_the_commands(tmp_path):
root, etc = project(tmp_path, name='LED Matrix')
assert setup(root, etc, FakeSystemctl()).ensure(ON)['status'] == 'installed'
service = (etc / aus.SERVICE_UNIT).read_text()
assert f'ExecStartPre=/bin/rm -f "{root}/data/auto_update_verify.request"' in service
assert f'ExecStart=/usr/bin/python3 "{root}/data/auto_update_verifier.py" "{root}"' in service
def test_a_folder_name_systemd_would_reinterpret_is_refused(tmp_path):
root, etc = project(tmp_path, name='LED%Matrix')
systemctl = FakeSystemctl()
out = setup(root, etc, systemctl).ensure(ON)
assert out['status'] == 'failed' and 'systemd cannot use' in out['message']
assert not (etc / aus.SERVICE_UNIT).exists() and systemctl.calls == []
def test_installer_sets_the_toggle_without_a_half_written_config(tmp_path):
"""first_time_install.sh may run while the display service watches
config.json; the file must be replaced whole, never truncated in place."""
import re
text = (ROOT / 'first_time_install.sh').read_text(encoding='utf-8').replace('\r\n', '\n')
script = next(s for s in re.findall(r"<<'PY'\n(.*?)\nPY\n", text, re.S) if 'auto_update' in s)
assert 'os.replace(' in script
config = tmp_path / 'config.json'
config.write_text(json.dumps({'timezone': 'UTC', 'auto_update': {'enabled': False}}))
run = subprocess.run([sys.executable, '-', str(config), '1'], input=script, text=True,
capture_output=True)
assert run.returncode == 0, run.stderr
assert json.loads(config.read_text()) == {'timezone': 'UTC', 'auto_update': {'enabled': True}}
assert [p.name for p in tmp_path.iterdir()] == ['config.json'], "a temp file was left behind"
def test_the_result_is_kept_when_it_cannot_be_given_to_the_web_user(tmp_path, monkeypatch):
"""Caught by CI, which runs as a non-root Linux user: chown needs root, and
a failed chown used to discard the result, so the General tab never
learned whether setup worked."""
def refuse(*args):
raise PermissionError('Operation not permitted')
monkeypatch.setattr(aus.os, 'chown', refuse, raising=False)
root, etc = project(tmp_path)
out = setup(root, etc, FakeSystemctl()).ensure(ON)
assert out['status'] == 'installed'
assert result(root) == out