mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 09:06:36 +00:00
Merge branch 'claude/hdpi-scroll-performance-antialiasing-4ae609' into claude/offscreen-rendering
This commit is contained in:
@@ -320,5 +320,6 @@ def test_units_installers_and_updater_agree():
|
||||
assert (f'systemd/{unit}', f'/etc/systemd/system/{unit}') in StartupValidator._UNITS
|
||||
|
||||
# Triggering takes no privilege any more; no sudoers rule should linger.
|
||||
for sudoers in ('scripts/install/configure_web_sudo.sh', 'first_time_install.sh'):
|
||||
for sudoers in ('scripts/install/configure_web_sudo.sh', 'first_time_install.sh',
|
||||
'scripts/install/lib_sudoers.sh'):
|
||||
assert not re.search(r'NOPASSWD:.*update-verify', (ROOT / sudoers).read_text(encoding='utf-8')), sudoers
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"""The one BDF loader and the one BDF rasterizer (src/common/bdf_font.py).
|
||||
|
||||
DisplayManager, the plugin test harness (VisualTestDisplayManager), FontManager
|
||||
and element_style used to carry their own copies of both. Plugin golden images
|
||||
depend on the exact pixels, so the rasterizer is checked against a frozen copy
|
||||
of the per-pixel loop DisplayManager._draw_bdf_text ran before it was shared
|
||||
(``_reference_draw`` below) for every bundled BDF font, at native and off-strike
|
||||
sizes, clipped and unclipped. Pixels are compared as raw bytes, never PNG
|
||||
hashes, so a Pillow upgrade can't fake or mask a difference.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import freetype
|
||||
import pytest
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
os.environ.setdefault("EMULATOR", "true")
|
||||
|
||||
from src.common import bdf_font
|
||||
from src.common.bdf_font import draw_bdf_text, load_bdf_face, read_bdf_native_size
|
||||
|
||||
FONTS_DIR = Path(__file__).resolve().parent.parent / "assets" / "fonts"
|
||||
BDF_FONTS = sorted(p.name for p in FONTS_DIR.glob("*.bdf"))
|
||||
|
||||
STRINGS = [
|
||||
"Hello, World!", "0123456789", "12:34 PM", "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~",
|
||||
"AaBbGgJjQqYy|", "", " ", "café üñ 72°F —€",
|
||||
]
|
||||
|
||||
|
||||
def _reference_draw(draw, width, height, text, x, y, face, color):
|
||||
"""DisplayManager._draw_bdf_text as it was before the shared rasterizer.
|
||||
|
||||
Frozen on purpose: it is the definition of the panel's output that the
|
||||
fast path must reproduce. Do not "fix" it.
|
||||
"""
|
||||
try:
|
||||
ascender_px = face.size.ascender >> 6
|
||||
except Exception:
|
||||
ascender_px = 0
|
||||
baseline_y = y + ascender_px
|
||||
for char in text:
|
||||
face.load_char(char)
|
||||
bitmap = face.glyph.bitmap
|
||||
glyph_left = face.glyph.bitmap_left
|
||||
glyph_top = face.glyph.bitmap_top
|
||||
for i in range(bitmap.rows):
|
||||
for j in range(bitmap.width):
|
||||
byte_index = i * bitmap.pitch + (j // 8)
|
||||
if byte_index < len(bitmap.buffer):
|
||||
byte = bitmap.buffer[byte_index]
|
||||
if byte & (1 << (7 - (j % 8))):
|
||||
pixel_x = x + glyph_left + j
|
||||
pixel_y = baseline_y - glyph_top + i
|
||||
if 0 <= pixel_x < width and 0 <= pixel_y < height:
|
||||
draw.point((pixel_x, pixel_y), fill=color)
|
||||
x += face.glyph.advance.x >> 6
|
||||
|
||||
|
||||
def _pair(size=(64, 32), mode="RGB", draw_mode=None):
|
||||
a = Image.new(mode, size)
|
||||
b = Image.new(mode, size)
|
||||
return a, ImageDraw.Draw(a, draw_mode), b, ImageDraw.Draw(b, draw_mode)
|
||||
|
||||
|
||||
def _assert_same(expected, actual, what):
|
||||
assert expected.tobytes() == actual.tobytes(), what
|
||||
|
||||
|
||||
def _sizes(name):
|
||||
native = read_bdf_native_size(str(FONTS_DIR / name))
|
||||
return sorted({native, native + 3, max(1, native - 2)})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- rasterizer
|
||||
|
||||
@pytest.mark.parametrize("name", BDF_FONTS)
|
||||
def test_every_bundled_font_matches_the_reference_raster(name):
|
||||
path = str(FONTS_DIR / name)
|
||||
w, h = 96, 24
|
||||
cases = [((0, 0), (255, 255, 255), text) for text in STRINGS]
|
||||
# Clipped on every edge, in a colour that isn't all-or-nothing per channel.
|
||||
cases += [(xy, (12, 200, 77), text)
|
||||
for xy in ((-4, -3), (w - 11, h - 5), (w // 2, -9))
|
||||
for text in STRINGS[:2]]
|
||||
for size in _sizes(name):
|
||||
face, _ = load_bdf_face(path, size)
|
||||
for (x, y), color, text in cases:
|
||||
ref, rdraw, new, ndraw = _pair((w, h))
|
||||
_reference_draw(rdraw, w, h, text, x, y, face, color)
|
||||
draw_bdf_text(ndraw, text, x, y, face, color)
|
||||
_assert_same(ref, new, (name, size, x, y, color, text))
|
||||
|
||||
|
||||
def test_returns_the_pen_position():
|
||||
face, _ = load_bdf_face(str(FONTS_DIR / "5x7.bdf"), 7)
|
||||
_, _, _, draw = _pair()
|
||||
assert draw_bdf_text(draw, "", 3, 0, face) == 3
|
||||
assert draw_bdf_text(draw, "12:34", 3, 0, face) == 3 + 5 * 5
|
||||
|
||||
|
||||
def test_clip_smaller_than_the_canvas_matches_the_reference():
|
||||
# DisplayManager clips to its logical size, which is the canvas size in
|
||||
# practice but not by construction; honour the clip as the loop did.
|
||||
face, _ = load_bdf_face(str(FONTS_DIR / "6x10.bdf"), 10)
|
||||
for clip in ((20, 7), (1, 1), (0, 0), (200, 200)):
|
||||
ref, rdraw, new, ndraw = _pair()
|
||||
_reference_draw(rdraw, clip[0], clip[1], "Mixed 123", -2, -1, face, (1, 2, 3))
|
||||
draw_bdf_text(ndraw, "Mixed 123", -2, -1, face, (1, 2, 3), clip=clip)
|
||||
_assert_same(ref, new, clip)
|
||||
|
||||
|
||||
def test_blending_draw_blends_exactly_like_the_reference():
|
||||
# ImageDraw.Draw(rgb, "RGBA") blends a translucent colour; a mask fill
|
||||
# would not, so this path must fall back to points.
|
||||
face, _ = load_bdf_face(str(FONTS_DIR / "7x13B.bdf"), 13)
|
||||
ref, rdraw, new, ndraw = _pair(draw_mode="RGBA")
|
||||
for img in (ref, new):
|
||||
img.paste((40, 80, 120), (0, 0, *img.size))
|
||||
_reference_draw(rdraw, 64, 32, "Blend", 1, 1, face, (255, 0, 0, 128))
|
||||
draw_bdf_text(ndraw, "Blend", 1, 1, face, (255, 0, 0, 128))
|
||||
_assert_same(ref, new, "blend")
|
||||
colours = {c for _, c in new.getcolors()}
|
||||
assert (255, 0, 0) not in colours and len(colours) == 2, colours # blended
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode,color", [("L", 200), ("P", (255, 0, 0)), ("RGBA", (9, 8, 7, 255)), ("1", 1)])
|
||||
def test_other_canvas_modes_match_the_reference(mode, color):
|
||||
face, _ = load_bdf_face(str(FONTS_DIR / "5x8.bdf"), 8)
|
||||
ref, rdraw, new, ndraw = _pair(mode=mode)
|
||||
_reference_draw(rdraw, 64, 32, "Mode 42", 2, 2, face, color)
|
||||
draw_bdf_text(ndraw, "Mode 42", 2, 2, face, color)
|
||||
_assert_same(ref, new, mode)
|
||||
|
||||
|
||||
def test_a_non_mono_face_reads_the_same_bits_as_the_reference():
|
||||
# A plugin can hand draw_text a freetype.Face of a TTF: 8-bit gray glyphs
|
||||
# whose pitch is not ceil(width/8). The loop read them as packed bits, and
|
||||
# so must the fast path -- same (odd) pixels, not "better" ones.
|
||||
face = freetype.Face(str(FONTS_DIR / "PressStart2P-Regular.ttf"))
|
||||
face.set_char_size(8 * 64, 8 * 64, 72, 72)
|
||||
ref, rdraw, new, ndraw = _pair()
|
||||
_reference_draw(rdraw, 64, 32, "Gray", 0, 0, face, (255, 255, 255))
|
||||
draw_bdf_text(ndraw, "Gray", 0, 0, face, (255, 255, 255))
|
||||
_assert_same(ref, new, "gray face")
|
||||
|
||||
|
||||
def test_errors_propagate_after_earlier_glyphs_are_drawn():
|
||||
face, _ = load_bdf_face(str(FONTS_DIR / "5x7.bdf"), 7)
|
||||
_, _, img, draw = _pair()
|
||||
with pytest.raises(Exception):
|
||||
draw_bdf_text(draw, "A", 0, 0, face, "not-a-colour")
|
||||
# Blank glyphs never touch the colour, as before.
|
||||
draw_bdf_text(draw, " ", 0, 0, face, "not-a-colour")
|
||||
assert img.getbbox() is None
|
||||
with pytest.raises(Exception):
|
||||
draw_bdf_text(draw, "A", 0, 0, object())
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- callers
|
||||
|
||||
def _dm_stub(img):
|
||||
from src.display_manager import DisplayManager
|
||||
stub = types.SimpleNamespace(width=img.width, height=img.height,
|
||||
draw=ImageDraw.Draw(img), calendar_font=None)
|
||||
return DisplayManager, stub
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["5x7.bdf", "tom-thumb.bdf", "9x18B.bdf", "MatrixChunky8X.bdf"])
|
||||
def test_display_manager_and_test_harness_draw_the_reference_pixels(name):
|
||||
from src.plugin_system.testing.visual_display_manager import VisualTestDisplayManager
|
||||
|
||||
face, _ = load_bdf_face(str(FONTS_DIR / name), 20) # off-strike for all four
|
||||
for text in STRINGS:
|
||||
ref, rdraw, dm_img, _ = _pair((64, 32))
|
||||
_reference_draw(rdraw, 64, 32, text, -1, 3, face, (255, 128, 0))
|
||||
|
||||
DisplayManager, stub = _dm_stub(dm_img)
|
||||
DisplayManager._draw_bdf_text(stub, text, -1, 3, (255, 128, 0), face)
|
||||
_assert_same(ref, dm_img, ("DisplayManager", name, text))
|
||||
|
||||
vt = VisualTestDisplayManager(64, 32)
|
||||
vt.draw_text(text, -1, 3, (255, 128, 0), font=face)
|
||||
_assert_same(ref, vt.image, ("VisualTestDisplayManager", name, text))
|
||||
|
||||
|
||||
def test_harness_calendar_font_is_the_panels():
|
||||
# The harness built a bare freetype.Face with no size: ascender 0, so
|
||||
# every calendar_font line drew a baseline too high, and its
|
||||
# get_font_height() returned 0.
|
||||
from src.plugin_system.testing.visual_display_manager import VisualTestDisplayManager
|
||||
|
||||
vt = VisualTestDisplayManager(64, 32)
|
||||
panel_face, _ = load_bdf_face(str(FONTS_DIR / "5x7.bdf"), 7)
|
||||
assert vt.calendar_font is panel_face
|
||||
assert vt.get_font_height(vt.calendar_font) == panel_face.size.height >> 6 > 0
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- loader
|
||||
|
||||
def test_off_strike_size_loads_the_native_strike():
|
||||
path = str(FONTS_DIR / "5x7.bdf")
|
||||
face, realised = load_bdf_face(path, 10)
|
||||
assert isinstance(face, freetype.Face)
|
||||
assert realised == 7 and face.size.y_ppem == 7
|
||||
assert load_bdf_face(path, 7)[1] == 7
|
||||
|
||||
|
||||
def test_faces_are_cached_and_shared_by_every_loader():
|
||||
from src.element_style import _load_bdf
|
||||
from src.font_manager import FontManager
|
||||
|
||||
path = str(FONTS_DIR / "6x10.bdf")
|
||||
face, realised = load_bdf_face(path, 12)
|
||||
assert load_bdf_face(path, 12)[0] is face
|
||||
assert _load_bdf(path, 12) == (face, realised)
|
||||
assert FontManager({})._load_bdf_font(path, 12) is face
|
||||
|
||||
|
||||
def test_touched_file_is_reloaded(tmp_path):
|
||||
# The cache key carries the file's mtime, so a font re-uploaded under the
|
||||
# same name is not served from a stale face.
|
||||
target = tmp_path / "f.bdf"
|
||||
target.write_bytes((FONTS_DIR / "5x7.bdf").read_bytes())
|
||||
first, _ = load_bdf_face(str(target), 7)
|
||||
assert load_bdf_face(str(target), 7)[0] is first
|
||||
os.utime(target, ns=(10**9, 10**9))
|
||||
assert load_bdf_face(str(target), 7)[0] is not first
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32",
|
||||
reason="FreeType holds the font file open; Windows refuses to overwrite it")
|
||||
def test_replaced_file_is_reloaded(tmp_path):
|
||||
target = tmp_path / "f.bdf"
|
||||
target.write_bytes((FONTS_DIR / "5x7.bdf").read_bytes())
|
||||
first, _ = load_bdf_face(str(target), 7)
|
||||
target.write_bytes((FONTS_DIR / "6x10.bdf").read_bytes())
|
||||
os.utime(target, ns=(1, 1))
|
||||
second, realised = load_bdf_face(str(target), 7)
|
||||
assert second is not first and realised == 10
|
||||
|
||||
|
||||
def test_unloadable_file_raises(tmp_path):
|
||||
with pytest.raises(Exception):
|
||||
load_bdf_face(str(tmp_path / "missing.bdf"), 7)
|
||||
bad = tmp_path / "bad.bdf"
|
||||
bad.write_text("not a font")
|
||||
with pytest.raises(Exception):
|
||||
load_bdf_face(str(bad), 7)
|
||||
|
||||
|
||||
def test_cache_is_bounded(monkeypatch):
|
||||
monkeypatch.setattr(bdf_font, "_FACE_CACHE_MAX", 2)
|
||||
bdf_font.clear_face_cache()
|
||||
path = str(FONTS_DIR / "5x7.bdf")
|
||||
for size in (7, 8, 9):
|
||||
load_bdf_face(path, size)
|
||||
assert len(bdf_font._face_cache) == 2
|
||||
bdf_font.clear_face_cache()
|
||||
|
||||
|
||||
def test_each_thread_gets_its_own_face():
|
||||
# FreeType forbids two threads using one face at once: load_char rewrites
|
||||
# the face's glyph slot. Within a thread the face is shared.
|
||||
import threading
|
||||
path = str(FONTS_DIR / "5x7.bdf")
|
||||
here, _ = load_bdf_face(path, 7)
|
||||
assert load_bdf_face(path, 7)[0] is here
|
||||
other = []
|
||||
worker = threading.Thread(target=lambda: other.append(load_bdf_face(path, 7)[0]))
|
||||
worker.start()
|
||||
worker.join()
|
||||
assert other and other[0] is not here
|
||||
|
||||
|
||||
def test_native_size_prefers_pixel_size_over_point_size():
|
||||
# 6x13.bdf is defined at 75dpi: SIZE says 12 (points), PIXEL_SIZE 13.
|
||||
assert read_bdf_native_size(str(FONTS_DIR / "6x13.bdf")) == 13
|
||||
assert read_bdf_native_size(str(FONTS_DIR / "nope.bdf")) is None
|
||||
@@ -0,0 +1,118 @@
|
||||
"""A stale cache record is recognised from its header, without parsing it.
|
||||
|
||||
The sports plugins cache whole season schedules -- 53MB for MLB, 18MB for NHL.
|
||||
When one expired, DiskCache.get parsed all of it (~1.8s of orjson.loads on a
|
||||
Pi 4, GIL held, the whole display frozen) only to find the timestamp too old
|
||||
and throw the result away. CacheManager.set now writes timestamp and ttl ahead
|
||||
of the data, and DiskCache.get reads them from the first bytes of the file.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from src.cache import disk_cache as disk_cache_module
|
||||
from src.cache.disk_cache import DiskCache, _stale_from_head
|
||||
from src.common import json_body
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disk(tmp_path):
|
||||
return DiskCache(cache_dir=str(tmp_path))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parses(monkeypatch):
|
||||
"""Count full parses of cache files."""
|
||||
calls = []
|
||||
real = disk_cache_module._loads
|
||||
|
||||
def counting(raw):
|
||||
calls.append(len(raw))
|
||||
return real(raw)
|
||||
|
||||
monkeypatch.setattr(disk_cache_module, "_loads", counting)
|
||||
return calls
|
||||
|
||||
|
||||
def _header_first(age=0.0, ttl=None, events=100):
|
||||
record = {"timestamp": time.time() - age}
|
||||
if ttl is not None:
|
||||
record["ttl"] = ttl
|
||||
record["data"] = {"events": [{"id": n, "name": "x" * 50} for n in range(events)]}
|
||||
return record
|
||||
|
||||
|
||||
def test_cache_manager_writes_the_header_first(monkeypatch):
|
||||
from src.cache_manager import CacheManager
|
||||
written = {}
|
||||
manager = CacheManager.__new__(CacheManager)
|
||||
monkeypatch.setattr(manager, "save_cache",
|
||||
lambda key, record: written.update({key: record}),
|
||||
raising=False)
|
||||
CacheManager.set(manager, "k", {"events": []}, ttl=60)
|
||||
assert list(written["k"]) == ["timestamp", "ttl", "data"]
|
||||
CacheManager.set(manager, "k", {"events": []})
|
||||
assert list(written["k"]) == ["timestamp", "data"]
|
||||
|
||||
|
||||
def test_a_stale_record_is_not_parsed(disk, parses):
|
||||
disk.set("season", _header_first(age=600))
|
||||
assert disk.get("season", max_age=300) is None
|
||||
assert parses == []
|
||||
|
||||
|
||||
def test_a_fresh_record_is_parsed_and_returned(disk, parses):
|
||||
disk.set("season", _header_first(age=10))
|
||||
record = disk.get("season", max_age=300)
|
||||
assert record["data"]["events"][0]["id"] == 0
|
||||
assert len(parses) == 1
|
||||
|
||||
|
||||
def test_the_entry_ttl_wins_over_max_age(disk, parses):
|
||||
disk.set("long", _header_first(age=600, ttl=3600))
|
||||
assert disk.get("long", max_age=300) is not None # ttl says fresh
|
||||
disk.set("short", _header_first(age=60, ttl=30))
|
||||
parses.clear()
|
||||
assert disk.get("short", max_age=300) is None # ttl says stale
|
||||
assert parses == []
|
||||
|
||||
|
||||
def test_no_limit_means_never_stale(disk):
|
||||
disk.set("forever", _header_first(age=10 ** 7))
|
||||
assert disk.get("forever", max_age=None) is not None
|
||||
|
||||
|
||||
def test_older_files_with_data_first_still_work(disk, parses):
|
||||
# Records written before the header moved: parsed in full, as before.
|
||||
disk.set("legacy_fresh", {"data": {"v": 1}, "timestamp": time.time()})
|
||||
disk.set("legacy_stale", {"data": {"v": 1}, "timestamp": time.time() - 600})
|
||||
assert disk.get("legacy_fresh", max_age=300)["data"] == {"v": 1}
|
||||
assert disk.get("legacy_stale", max_age=300) is None
|
||||
assert len(parses) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("head, stale", [
|
||||
(b'{"timestamp":100.0,"data":{}}', True),
|
||||
(b'{"timestamp": 100.0, "ttl": 1000, "data": {}}', False), # stdlib spacing
|
||||
(b'{"timestamp":1e2,"ttl":5,"data":1}', True),
|
||||
(b'{"timestamp":100.0}', True),
|
||||
(b'{"data":{},"timestamp":100.0}', False), # unknown layout
|
||||
(b'{"timestamp":"100.0","data":{}}', False), # string: parse it
|
||||
(b'', False),
|
||||
])
|
||||
def test_reading_the_header(head, stale):
|
||||
assert _stale_from_head(head, 300, now=1000.0) is stale
|
||||
|
||||
|
||||
def test_response_json_prefers_orjson_and_falls_back():
|
||||
payload = {"events": [1, 2, 3]}
|
||||
response = SimpleNamespace(content=json.dumps(payload).encode(),
|
||||
json=lambda: pytest.fail("used the slow path"))
|
||||
if json_body.orjson is None:
|
||||
pytest.skip("orjson not installed")
|
||||
assert json_body.response_json(response) == payload
|
||||
# A response object without bytes content (a test double) still works.
|
||||
assert json_body.response_json(SimpleNamespace(json=lambda: payload)) == payload
|
||||
@@ -16,9 +16,13 @@ change to the fallback chains is a deliberate one.
|
||||
|
||||
The `.standalone-backup-` contract: store_manager renames a plugin dir aside
|
||||
with that substring during install/rollback; discovery MUST skip such dirs
|
||||
or a half-finished install would surface a ghost plugin. The substring is
|
||||
duplicated as a literal in both files — this test breaks if either side
|
||||
changes it unilaterally.
|
||||
or a half-finished install would surface a ghost plugin. The substring now
|
||||
lives once, as plugin_dirs.BACKUP_MARKER, which both sides import; its value
|
||||
is pinned because debris already on devices carries exactly that text.
|
||||
|
||||
All of them now resolve through src/plugin_system/plugin_dirs.py; the
|
||||
per-caller differences pinned here are explicit arguments there. The rules
|
||||
themselves are covered table-style in test_plugin_dirs.py.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -198,14 +202,16 @@ class TestStandaloneBackupContract:
|
||||
found = _scanner()._scan_directory_for_plugins(plugins_dir)
|
||||
assert found == ["real-plugin"]
|
||||
|
||||
def test_backup_substring_literal_matches_across_files(self):
|
||||
"""The substring is duplicated in plugin_manager (skip check) and
|
||||
store_manager (rename-aside names). If either side changes it, the
|
||||
other silently stops honoring the contract — this test is the
|
||||
tripwire."""
|
||||
def test_backup_marker_is_shared_and_unchanged(self):
|
||||
"""store_manager (rename-aside names) and every lookup (skip check)
|
||||
must agree on the marker. Both now import one constant; the value is
|
||||
pinned because renaming it would make existing debris on devices
|
||||
visible as plugins again."""
|
||||
from src.plugin_system import plugin_dirs
|
||||
assert plugin_dirs.BACKUP_MARKER == '.standalone-backup-'
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
pm_text = (root / "src/plugin_system/plugin_manager.py").read_text()
|
||||
sm_text = (root / "src/plugin_system/store_manager.py").read_text()
|
||||
assert "'.standalone-backup-'" in pm_text.replace('"', "'")
|
||||
assert ".standalone-backup-" in sm_text
|
||||
|
||||
sm_text = (root / "src/plugin_system/store_manager.py").read_text(encoding="utf-8")
|
||||
assert "{BACKUP_MARKER}preinstall" in sm_text
|
||||
assert "{BACKUP_MARKER}migrating" in sm_text
|
||||
assert plugin_dirs.is_ignored_dir_name(
|
||||
"demo" + plugin_dirs.BACKUP_MARKER + "preinstall")
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
Every "which directory holds plugin X?" answer, from one tree, per caller.
|
||||
|
||||
src/plugin_system/plugin_dirs.py holds the rules; the callers differ only in
|
||||
explicit arguments (search dirs, ``ledmatrix-`` prefix, case folding, whether
|
||||
the manifest pass runs). This file builds one project tree that exercises
|
||||
every rule and pins each caller's answer for each id, so a change to either
|
||||
the shared rules or a caller's arguments shows up as a table row.
|
||||
|
||||
Callers:
|
||||
discovery PluginManager._scan_directory_for_plugins -> plugin_directories
|
||||
pm_get PluginManager.get_plugin_directory before discovery has run
|
||||
loader PluginLoader.find_plugin_directory (no discovery mapping)
|
||||
store PluginStoreManager._find_plugin_path
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system import plugin_dirs
|
||||
from src.plugin_system.plugin_dirs import (
|
||||
ManifestStatus, PluginDirectoryIndex, resolve_plugin_dir,
|
||||
)
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
from src.plugin_system.state_reconciliation import (
|
||||
StateReconciliation, disk_plugin_ids,
|
||||
)
|
||||
from src.plugin_system.store_manager import PluginStoreManager
|
||||
|
||||
CONFIGURED = "plugin-repos"
|
||||
SIBLING = "plugins"
|
||||
|
||||
|
||||
def _write(base: Path, dir_name: str, manifest) -> Path:
|
||||
d = base / dir_name
|
||||
d.mkdir(parents=True)
|
||||
if manifest is not None:
|
||||
text = manifest if isinstance(manifest, str) else json.dumps(manifest)
|
||||
(d / "manifest.json").write_text(text, encoding="utf-8")
|
||||
return d
|
||||
|
||||
|
||||
def _plugin(base: Path, dir_name: str, plugin_id: str, **extra) -> Path:
|
||||
return _write(base, dir_name, dict({"id": plugin_id, "name": plugin_id,
|
||||
"version": "1.0.0"}, **extra))
|
||||
|
||||
|
||||
def _link_dir(link: Path, target: Path) -> None:
|
||||
"""A symlink where the OS allows one; on Windows without the privilege,
|
||||
a directory junction, which the code under test sees the same way
|
||||
(is_dir() follows it, iterdir() lists it under the link's name)."""
|
||||
try:
|
||||
os.symlink(target, link, target_is_directory=True)
|
||||
except OSError:
|
||||
if sys.platform != "win32":
|
||||
raise
|
||||
import _winapi
|
||||
_winapi.CreateJunction(str(target), str(link))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tree(tmp_path):
|
||||
repos = tmp_path / CONFIGURED
|
||||
legacy = tmp_path / SIBLING
|
||||
repos.mkdir()
|
||||
legacy.mkdir()
|
||||
|
||||
# configured dir (plugin-repos/)
|
||||
_plugin(repos, "exact", "exact") # id == dir name
|
||||
_plugin(repos, "ledmatrix-stocks", "stocks") # manifest id != dir name
|
||||
_plugin(repos, "ledmatrix-legacy", "ledmatrix-legacy") # prefix only by name
|
||||
_plugin(repos, "MixedCase", "MixedCase") # case differences
|
||||
_plugin(repos, "renamed-dir", "other-id") # dir name belongs to no id
|
||||
_plugin(repos, "shadow", "not-shadow") # name says one id ...
|
||||
_plugin(repos, "real-shadow", "shadow") # ... manifest says it's here
|
||||
_plugin(repos, "ghost.standalone-backup-preinstall", "ghost") # set aside
|
||||
_plugin(repos, "exact.standalone-backup-migrating", "exact") # set aside, dup id
|
||||
_plugin(repos, ".hidden", "hidden") # hidden / staging
|
||||
_plugin(repos, "zz-dupe", "dupe") # duplicate ids:
|
||||
_plugin(repos, "ledmatrix-dupe", "dupe") # prefix beats other,
|
||||
_plugin(repos, "dupe", "dupe") # exact beats prefix
|
||||
_write(repos, "broken", "{ not json") # unreadable manifest
|
||||
_write(repos, "noid", {"name": "No id"}) # parses, no id
|
||||
_write(repos, "listy", [1, 2]) # parses, not an object
|
||||
_write(repos, "nomanifest", None) # not a plugin
|
||||
_plugin(repos, "both", "both") # also in plugins/
|
||||
_plugin(repos, "ledmatrix-weather", "weather") # manifest hit here vs
|
||||
(repos / "README.md").write_text("not a dir") # a file, never a match
|
||||
|
||||
dev_target = _plugin(tmp_path / "dev-checkouts", "devplug-src", "devplug")
|
||||
_link_dir(repos / "dev-link", dev_target) # symlinked dev plugin
|
||||
|
||||
# sibling dir (plugins/): store fallback only
|
||||
_plugin(legacy, "both", "both")
|
||||
_plugin(legacy, "legacy-only", "legacy-only")
|
||||
_plugin(legacy, "ledmatrix-sibling", "sibling")
|
||||
_plugin(legacy, "weather", "weather") # ... a name hit here
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _discovery(root: Path) -> PluginManager:
|
||||
pm = object.__new__(PluginManager)
|
||||
pm.logger = logging.getLogger("test_plugin_dirs")
|
||||
pm._discovery_lock = threading.RLock()
|
||||
pm._skip_reported = set()
|
||||
pm.plugin_manifests = {}
|
||||
pm.plugin_directories = {}
|
||||
pm.plugins_dir = root / CONFIGURED
|
||||
return pm
|
||||
|
||||
|
||||
def _answers(root: Path, plugin_id: str) -> dict:
|
||||
repos = root / CONFIGURED
|
||||
discovered = _discovery(root)
|
||||
discovered._scan_directory_for_plugins(repos)
|
||||
fresh = _discovery(root) # discovery not run: exercises the disk rules
|
||||
store = PluginStoreManager(plugins_dir=str(repos),
|
||||
uninstalled_registry_path=str(root / "u.json"))
|
||||
|
||||
def rel(p):
|
||||
return None if p is None else Path(p).relative_to(root).as_posix()
|
||||
|
||||
return {
|
||||
"discovery": rel(discovered.plugin_directories.get(plugin_id)),
|
||||
"pm_get": rel(fresh.get_plugin_directory(plugin_id)),
|
||||
"loader": rel(PluginLoader().find_plugin_directory(plugin_id, repos)),
|
||||
"store": rel(store._find_plugin_path(plugin_id)),
|
||||
}
|
||||
|
||||
|
||||
R = CONFIGURED + "/"
|
||||
S = SIBLING + "/"
|
||||
|
||||
# id discovery pm_get loader store
|
||||
TABLE = [
|
||||
("exact", R + "exact", R + "exact", R + "exact", R + "exact"),
|
||||
# manifest id != dir name: the manifest finds it; pm_get by prefix
|
||||
("stocks", R + "ledmatrix-stocks", R + "ledmatrix-stocks",
|
||||
R + "ledmatrix-stocks", R + "ledmatrix-stocks"),
|
||||
# ledmatrix- prefix: name-only callers with prefix=True; the store has none
|
||||
("legacy", None, R + "ledmatrix-legacy", R + "ledmatrix-legacy", None),
|
||||
("ledmatrix-legacy", R + "ledmatrix-legacy", R + "ledmatrix-legacy",
|
||||
R + "ledmatrix-legacy", R + "ledmatrix-legacy"),
|
||||
# case: only the loader folds case
|
||||
("mixedcase", None, None, R + "MixedCase", None),
|
||||
("MixedCase", R + "MixedCase", R + "MixedCase", R + "MixedCase", R + "MixedCase"),
|
||||
# a directory name no manifest claims still resolves by name
|
||||
("renamed-dir", None, R + "renamed-dir", R + "renamed-dir", R + "renamed-dir"),
|
||||
("other-id", R + "renamed-dir", None, R + "renamed-dir", R + "renamed-dir"),
|
||||
# manifest id wins over directory name (pm_get has no manifest pass)
|
||||
("shadow", R + "real-shadow", R + "shadow", R + "real-shadow", R + "real-shadow"),
|
||||
# backups and hidden dirs are never plugins, by id or by name
|
||||
("ghost", None, None, None, None),
|
||||
("ghost.standalone-backup-preinstall", None, None, None, None),
|
||||
("hidden", None, None, None, None),
|
||||
(".hidden", None, None, None, None),
|
||||
# duplicate ids: exact name, then ledmatrix-<id>, then by name
|
||||
("dupe", R + "dupe", R + "dupe", R + "dupe", R + "dupe"),
|
||||
# unreadable manifest: found by name so it can be repaired/removed
|
||||
("broken", None, R + "broken", R + "broken", R + "broken"),
|
||||
("noid", None, R + "noid", R + "noid", R + "noid"),
|
||||
("nomanifest", None, R + "nomanifest", R + "nomanifest", R + "nomanifest"),
|
||||
("README.md", None, None, None, None),
|
||||
# symlinked dev plugin: found through the link, path kept inside the dir
|
||||
("devplug", R + "dev-link", None, R + "dev-link", R + "dev-link"),
|
||||
("dev-link", None, R + "dev-link", R + "dev-link", R + "dev-link"),
|
||||
# search order: only the store looks in plugins/, and configured first
|
||||
("both", R + "both", R + "both", R + "both", R + "both"),
|
||||
("legacy-only", None, None, None, S + "legacy-only"),
|
||||
("sibling", None, None, None, S + "ledmatrix-sibling"),
|
||||
# configured dir searched completely (manifest hit) before plugins/ (name hit)
|
||||
("weather", R + "ledmatrix-weather", R + "ledmatrix-weather",
|
||||
R + "ledmatrix-weather", R + "ledmatrix-weather"),
|
||||
# not one plain path segment: nothing, never a join or a truncation
|
||||
("../plugins/both", None, None, None, None),
|
||||
("plugin-repos/exact", None, None, None, None),
|
||||
("", None, None, None, None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("plugin_id,discovery,pm_get,loader,store", TABLE,
|
||||
ids=[row[0] or "<empty>" for row in TABLE])
|
||||
def test_each_caller_resolves_each_id(tree, plugin_id, discovery, pm_get, loader, store):
|
||||
assert _answers(tree, plugin_id) == {
|
||||
"discovery": discovery, "pm_get": pm_get, "loader": loader, "store": store,
|
||||
}
|
||||
|
||||
|
||||
class TestListings:
|
||||
def test_discovery_registers_manifest_ids_only(self, tree):
|
||||
pm = _discovery(tree)
|
||||
found = pm._scan_directory_for_plugins(tree / CONFIGURED)
|
||||
assert sorted(found) == sorted([
|
||||
"exact", "stocks", "ledmatrix-legacy", "MixedCase", "other-id",
|
||||
"not-shadow", "shadow", "dupe", "both", "weather", "devplug",
|
||||
])
|
||||
assert len(found) == len(set(found)), "a duplicate id was listed twice"
|
||||
assert set(pm.plugin_manifests) == set(found)
|
||||
|
||||
def test_store_lists_every_dir_with_a_manifest(self, tree):
|
||||
store = PluginStoreManager(plugins_dir=str(tree / CONFIGURED),
|
||||
uninstalled_registry_path=str(tree / "u.json"))
|
||||
assert store.list_installed_plugins() == sorted([
|
||||
"exact", "stocks", "ledmatrix-legacy", "MixedCase", "other-id",
|
||||
"not-shadow", "shadow", "dupe", "both", "weather", "devplug",
|
||||
# manifest present but no usable id: listed by directory name
|
||||
"broken", "noid", "listy",
|
||||
])
|
||||
|
||||
def test_reconciliation_counts_parseable_manifests(self, tree):
|
||||
assert disk_plugin_ids(tree / CONFIGURED) == {
|
||||
"exact", "stocks", "ledmatrix-legacy", "MixedCase", "other-id",
|
||||
"not-shadow", "shadow", "dupe", "both", "weather", "devplug",
|
||||
"noid", "listy",
|
||||
}
|
||||
|
||||
def test_reconciliation_disk_state_is_keyed_like_config(self, tree):
|
||||
recon = object.__new__(StateReconciliation)
|
||||
recon.plugins_dir = tree / CONFIGURED
|
||||
recon.logger = logging.getLogger("test_plugin_dirs")
|
||||
state = recon._get_disk_state()
|
||||
assert state["stocks"] == {"exists_on_disk": True, "version": "1.0.0",
|
||||
"name": "stocks"}
|
||||
assert "ledmatrix-stocks" not in state
|
||||
# A manifest that is valid JSON but not an object used to abort the
|
||||
# whole disk state with AttributeError.
|
||||
assert state["listy"] == {"exists_on_disk": True, "version": None, "name": None}
|
||||
|
||||
def test_all_listings_skip_backups_and_hidden(self, tree):
|
||||
store = PluginStoreManager(plugins_dir=str(tree / CONFIGURED),
|
||||
uninstalled_registry_path=str(tree / "u.json"))
|
||||
pm = _discovery(tree)
|
||||
listings = {
|
||||
"discovery": set(pm._scan_directory_for_plugins(tree / CONFIGURED)),
|
||||
"store": set(store.list_installed_plugins()),
|
||||
"reconciliation": disk_plugin_ids(tree / CONFIGURED),
|
||||
}
|
||||
for name, ids in listings.items():
|
||||
assert not {"ghost", "hidden", ".hidden"} & ids, name
|
||||
assert not any(plugin_dirs.BACKUP_MARKER in i for i in ids), name
|
||||
# the backup of `exact` did not replace the live one
|
||||
assert pm.plugin_directories["exact"] == tree / CONFIGURED / "exact"
|
||||
|
||||
|
||||
class TestIndex:
|
||||
def test_each_manifest_is_read_once_per_scan(self, tree, monkeypatch):
|
||||
reads = []
|
||||
real = plugin_dirs._read_entry
|
||||
monkeypatch.setattr(plugin_dirs, "_read_entry",
|
||||
lambda p: reads.append(p.name) or real(p))
|
||||
index = PluginDirectoryIndex.scan(tree / CONFIGURED)
|
||||
for plugin_id in ("exact", "stocks", "dupe", "shadow", "nope"):
|
||||
index.find(plugin_id, prefix=True, case_insensitive=True)
|
||||
index.plugins()
|
||||
index.installed_ids(require_parseable_manifest=True)
|
||||
assert len(reads) == len(set(reads)) == len(index.entries)
|
||||
|
||||
def test_manifest_statuses(self, tree):
|
||||
index = PluginDirectoryIndex.scan(tree / CONFIGURED)
|
||||
status = {e.name: e.status for e in index.entries}
|
||||
assert status["exact"] == ManifestStatus.OK
|
||||
assert status["broken"] == ManifestStatus.UNREADABLE
|
||||
assert status["noid"] == ManifestStatus.NO_ID
|
||||
assert status["listy"] == ManifestStatus.NOT_OBJECT
|
||||
assert status["nomanifest"] == ManifestStatus.MISSING
|
||||
assert "README.md" not in status
|
||||
|
||||
def test_duplicates_are_reported_with_every_claimant(self, tree):
|
||||
dupes = PluginDirectoryIndex.scan(tree / CONFIGURED).duplicates()
|
||||
assert sorted(e.name for e in dupes["dupe"]) == \
|
||||
["dupe", "ledmatrix-dupe", "zz-dupe"]
|
||||
# the backup of `exact` is not a claimant
|
||||
assert "exact" not in dupes
|
||||
|
||||
def test_duplicate_preference_without_an_exact_name(self, tmp_path):
|
||||
_plugin(tmp_path, "zz-dupe", "dupe")
|
||||
_plugin(tmp_path, "aa-dupe", "dupe")
|
||||
_plugin(tmp_path, "ledmatrix-dupe", "dupe")
|
||||
assert resolve_plugin_dir("dupe", [tmp_path], prefix=False) == \
|
||||
tmp_path / "ledmatrix-dupe"
|
||||
(tmp_path / "ledmatrix-dupe" / "manifest.json").unlink()
|
||||
assert resolve_plugin_dir("dupe", [tmp_path], prefix=False) == \
|
||||
tmp_path / "aa-dupe"
|
||||
|
||||
def test_exact_name_beats_prefix_even_when_it_sorts_later(self, tmp_path):
|
||||
_plugin(tmp_path, "ledmatrix-zeta", "zeta")
|
||||
_plugin(tmp_path, "zeta", "zeta")
|
||||
index = PluginDirectoryIndex.scan(tmp_path)
|
||||
assert index.plugins()["zeta"].name == "zeta"
|
||||
assert resolve_plugin_dir("zeta", [tmp_path], prefix=True) == tmp_path / "zeta"
|
||||
|
||||
@pytest.mark.parametrize("bad", [None, 5, b"exact", ["exact"]])
|
||||
def test_non_string_ids_resolve_to_nothing(self, tree, bad):
|
||||
for kwargs in ({"prefix": True}, {"prefix": True, "by_manifest": False},
|
||||
{"prefix": False, "case_insensitive": True}):
|
||||
assert resolve_plugin_dir(bad, [tree / CONFIGURED], **kwargs) is None
|
||||
|
||||
def test_missing_search_dir_is_empty_not_an_error(self, tmp_path):
|
||||
index = PluginDirectoryIndex.scan(tmp_path / "absent")
|
||||
assert index.entries == [] and index.error is None
|
||||
assert resolve_plugin_dir("x", [tmp_path / "absent"], prefix=True) is None
|
||||
|
||||
def test_discovery_warns_once_about_a_duplicate(self, tree, caplog):
|
||||
pm = _discovery(tree)
|
||||
with caplog.at_level(logging.WARNING, logger="test_plugin_dirs"):
|
||||
for _ in range(3):
|
||||
pm._scan_directory_for_plugins(tree / CONFIGURED)
|
||||
hits = [r for r in caplog.records if "'dupe'" in r.getMessage()]
|
||||
assert len(hits) == 1
|
||||
assert "zz-dupe" in hits[0].getMessage()
|
||||
|
||||
|
||||
class TestAutoUpdateUsesManifestIds:
|
||||
def test_update_targets_manifest_id_and_its_directory(self, tree, monkeypatch):
|
||||
from web_interface import auto_update
|
||||
|
||||
store = PluginStoreManager(plugins_dir=str(tree / CONFIGURED),
|
||||
uninstalled_registry_path=str(tree / "u.json"))
|
||||
calls = []
|
||||
|
||||
def fake_update(plugin_id):
|
||||
calls.append(plugin_id)
|
||||
if plugin_id == "stocks":
|
||||
path = tree / CONFIGURED / "ledmatrix-stocks" / "manifest.json"
|
||||
m = json.loads(path.read_text(encoding="utf-8"))
|
||||
m["version"] = "2.0.0"
|
||||
path.write_text(json.dumps(m), encoding="utf-8")
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(store, "update_plugin", fake_update)
|
||||
monkeypatch.setattr(store, "_get_local_git_info", lambda p: None)
|
||||
updated, failed = auto_update.update_plugins(store)
|
||||
assert "stocks" in calls and "ledmatrix-stocks" not in calls
|
||||
assert not any(plugin_dirs.BACKUP_MARKER in c for c in calls)
|
||||
# the version change was seen in ledmatrix-stocks/, not a missing stocks/
|
||||
assert updated == ["stocks"] and failed == []
|
||||
@@ -0,0 +1,110 @@
|
||||
"""redact_credentials must stay linear in the length of its input.
|
||||
|
||||
Regressions under test, both quadratic regexes in src/redaction.py:
|
||||
|
||||
- The URL-userinfo pattern (`scheme://user:password@`) could start a match at
|
||||
every letter of a run of scheme characters, and each attempt read to the end
|
||||
of the run looking for `://`: 1.6s for a 20k-character run.
|
||||
- The Authorization-header pattern had two `\\s*` separated only by an
|
||||
optional quote, so a header followed by whitespace and no credential tried
|
||||
every split of that whitespace between them: 8s for 20k spaces.
|
||||
|
||||
The display service redacts every message, stack trace and context value it
|
||||
publishes in the error snapshot, and re.sub holds the GIL throughout, so an
|
||||
exception quoting a hex digest or a long ID stalled the render loop with it.
|
||||
test_error_snapshot_cross_process.py's snapshot-size test spent 140s here.
|
||||
|
||||
The fixed patterns have to redact exactly what the old ones did.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from src.redaction import redact_credentials
|
||||
|
||||
# Each timed input took seconds before the fix and takes about a millisecond
|
||||
# after it; the bound leaves CI plenty of headroom while still failing on a
|
||||
# quadratic pattern.
|
||||
_TIME_LIMIT = 1.0
|
||||
|
||||
|
||||
def _timed(text):
|
||||
start = time.perf_counter()
|
||||
result = redact_credentials(text)
|
||||
return result, time.perf_counter() - start
|
||||
|
||||
|
||||
class TestUrlUserinfo:
|
||||
@pytest.mark.parametrize("text,expected", [
|
||||
("401 for https://user:hunter2@example.com/api",
|
||||
"401 for https://user:<redacted>@example.com/api"),
|
||||
("HTTPS://USER:HUNTER2@EXAMPLE.COM",
|
||||
"HTTPS://USER:<redacted>@EXAMPLE.COM"),
|
||||
("git+ssh://deploy:hunter2@host/repo",
|
||||
"git+ssh://deploy:<redacted>@host/repo"),
|
||||
# The scheme starts after digits or +.- in the same run. Those
|
||||
# characters must survive, and the password must still go.
|
||||
("1http://user:hunter2@host", "1http://user:<redacted>@host"),
|
||||
("+.-http://user:hunter2@host", "+.-http://user:<redacted>@host"),
|
||||
("a1+http://user:hunter2@host", "a1+http://user:<redacted>@host"),
|
||||
("see a://u:first@b and c://v:second@d",
|
||||
"see a://u:<redacted>@b and c://v:<redacted>@d"),
|
||||
])
|
||||
def test_password_is_redacted_and_the_rest_kept(self, text, expected):
|
||||
assert redact_credentials(text) == expected
|
||||
|
||||
def test_a_url_without_a_password_is_untouched(self):
|
||||
text = "GET https://user@example.com/path failed"
|
||||
assert redact_credentials(text) == text
|
||||
|
||||
|
||||
class TestAuthorizationHeader:
|
||||
@pytest.mark.parametrize("text,expected", [
|
||||
("Authorization: Bearer eyJ.SECRET.sig", "Authorization: Bearer <redacted>"),
|
||||
("Proxy-Authorization: Basic dXNlcg==", "Proxy-Authorization: Basic <redacted>"),
|
||||
("authorization: barecredential", "authorization: <redacted>"),
|
||||
# Whitespace and an opening quote around the value, in either order.
|
||||
('authorization=" Bearer tok"', 'authorization=" Bearer <redacted>"'),
|
||||
("authorization: ' tok'", "authorization: ' <redacted>'"),
|
||||
("authorization:\n\tBearer tok", "authorization:\n\tBearer <redacted>"),
|
||||
])
|
||||
def test_credential_is_redacted_and_the_rest_kept(self, text, expected):
|
||||
assert redact_credentials(text) == expected
|
||||
|
||||
@pytest.mark.parametrize("text", ["authorization: ", "authorization: , next"])
|
||||
def test_a_header_without_a_credential_is_untouched(self, text):
|
||||
assert redact_credentials(text) == text
|
||||
|
||||
|
||||
class TestLinearTime:
|
||||
@pytest.mark.parametrize("unit", ["x", "0123456789abcdef", "1a", "a+", "1"])
|
||||
def test_long_scheme_character_runs(self, unit):
|
||||
text = (unit * 50_000)[:50_000]
|
||||
result, elapsed = _timed(text)
|
||||
assert result == text
|
||||
assert elapsed < _TIME_LIMIT, f"{elapsed:.2f}s to redact {len(text)} chars of {unit!r}"
|
||||
|
||||
def test_a_credential_after_a_long_run_is_still_found(self):
|
||||
run = "ab12" * 10_000
|
||||
result, elapsed = _timed(f"{run} https://user:hunter2@example.com")
|
||||
assert result == f"{run} https://user:<redacted>@example.com"
|
||||
assert elapsed < _TIME_LIMIT
|
||||
|
||||
@pytest.mark.parametrize("header,whitespace", [
|
||||
("authorization:", " "),
|
||||
("Proxy-Authorization:", "\t"),
|
||||
("authorization=", "\n"),
|
||||
])
|
||||
def test_a_header_followed_by_long_whitespace(self, header, whitespace):
|
||||
text = header + whitespace * 20_000 + ","
|
||||
result, elapsed = _timed(text)
|
||||
assert result == text
|
||||
assert elapsed < _TIME_LIMIT, (
|
||||
f"{elapsed:.2f}s to redact {header!r} and {len(text) - len(header)} more chars")
|
||||
|
||||
def test_a_credential_after_long_whitespace_is_still_found(self):
|
||||
gap = " " * 20_000
|
||||
result, elapsed = _timed(f"authorization:{gap}Bearer tok")
|
||||
assert result == f"authorization:{gap}Bearer <redacted>"
|
||||
assert elapsed < _TIME_LIMIT
|
||||
@@ -0,0 +1,672 @@
|
||||
"""The twins: ``SportsCoreSharedMixin`` methods vs ``sports_card`` functions.
|
||||
|
||||
Every scoreboard draws the same game twice over: switch mode through its
|
||||
``sports.py`` (``SportsCoreSharedMixin``, ``self._recent_score_color(...)``)
|
||||
and scroll/Vegas mode through its ``game_renderer.py``
|
||||
(``sports_card``, ``_card.recent_score_color(...)``). The two modules grew
|
||||
same-named helpers independently, so this file calls each pair with the same
|
||||
inputs and says which ones agree.
|
||||
|
||||
Two kinds of test live here, and the difference matters:
|
||||
|
||||
* ``TestIdentical`` -- pairs that agree on every input below. Most mixin
|
||||
methods in this set are now thin wrappers over the ``sports_card`` function,
|
||||
so the check is also what keeps a future "fix" to one side from quietly
|
||||
becoming a divergence (a mixin body re-grown, a wrapper given different
|
||||
arguments).
|
||||
* ``TestPinnedDivergence`` -- pairs that do NOT agree. Their current behaviour
|
||||
is pinned on purpose, with the minimal input that shows the difference. A
|
||||
divergence here is user-visible (a colour, a weekday) in one display mode, and
|
||||
which side is right is an owner decision, not a refactor. When that decision
|
||||
is made, the test that pins it is the one to edit, deliberately.
|
||||
|
||||
The game corpus is the plugins' own harness fixtures
|
||||
(``plugins/*/test/fixtures/mock.json`` in ledmatrix-plugins), reduced to the
|
||||
keys these helpers read and embedded below, in the three payload shapes the
|
||||
helpers are handed: flat (what ``_extract_game_details_common`` builds -- the
|
||||
switch-mode input), flat plus nested (what the renderers'
|
||||
``_normalize_game_payload`` hands the scroll card), and nested only. Point
|
||||
``LEDMATRIX_PLUGINS`` at a ledmatrix-plugins checkout to add every event in
|
||||
those fixtures to the corpus.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
|
||||
from src.common import sports_card as C
|
||||
from src.common.font_layout import load_truetype, resolve_asset_path
|
||||
from src.common.sports_shared import SportsCoreSharedMixin
|
||||
|
||||
LOG = logging.getLogger("test_sports_twins")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Corpus
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: (plugin, start, home abbr, home id, home score, away abbr, away id,
|
||||
#: away score, state) -- one row per distinct event in the eight scoreboards'
|
||||
#: test/fixtures/mock.json.
|
||||
FIXTURE_EVENTS = [
|
||||
("afl", "2026-07-10T09:40Z", "COLL", "17", "89", "NMFC", "5", "85", "post"),
|
||||
("afl", "2026-07-11T03:15Z", "STK", "18", "0", "PORT", "7", "0", "pre"),
|
||||
("afl", "2026-07-10T11:30Z", "FRE", "1", "54", "SYD", "4", "48", "in"),
|
||||
("baseball", "2026-07-09T02:10Z", "LAD", "19", "5", "SF", "26", "3", "post"),
|
||||
("baseball", "2026-07-10T10:05Z", "NYY", "10", "4", "BOS", "2", "3", "in"),
|
||||
("baseball", "2026-07-11T23:10Z", "NYM", "21", "0", "ATL", "15", "0", "pre"),
|
||||
("basketball", "2026-01-14T00:30Z", "BOS", "2", "112", "NY", "18", "104", "post"),
|
||||
("basketball", "2026-01-15T03:30Z", "LAL", "13", "78", "GS", "9", "72", "in"),
|
||||
("basketball", "2026-01-16T02:00Z", "DEN", "7", "0", "DAL", "6", "0", "pre"),
|
||||
("football", "2026-01-14T01:15Z", "KC", "12", "27", "BUF", "2", "24", "post"),
|
||||
("football", "2026-01-15T10:30Z", "PHI", "21", "17", "DAL", "6", "14", "in"),
|
||||
("football", "2026-01-18T23:30Z", "DET", "8", "0", "GB", "9", "0", "pre"),
|
||||
("hockey", "2026-01-14T00:00Z", "BOS", "1", "4", "TOR", "21", "2", "post"),
|
||||
("hockey", "2026-01-15T10:30Z", "TB", "20", "3", "DAL", "9", "2", "in"),
|
||||
("hockey", "2026-01-16T00:00Z", "CHI", "4", "0", "NYR", "13", "0", "pre"),
|
||||
("lacrosse", "2026-04-14T18:00Z", "DUKE", "150", "14", "SYR", "183", "11", "post"),
|
||||
("lacrosse", "2026-04-15T10:30Z", "JHU", "2305", "8", "UVA", "258", "7", "in"),
|
||||
("lacrosse", "2026-04-16T22:00Z", "COR", "172", "0", "PSU", "213", "0", "pre"),
|
||||
("nrl", "2026-07-10T09:00Z", "BRI", "16", "18", "PEN", "18", "12", "in"),
|
||||
("nrl", "2026-07-09T09:00Z", "MEL", "12", "24", "SYD", "20", "10", "post"),
|
||||
("nrl", "2026-07-12T09:00Z", "PAR", "14", "0", "PEN", "18", "0", "pre"),
|
||||
("soccer", "2026-01-14T20:00Z", "ARS", "359", "2", "CHE", "363", "1", "post"),
|
||||
("soccer", "2026-01-15T11:30Z", "LIV", "364", "1", "MNC", "382", "1", "in"),
|
||||
("soccer", "2026-01-16T20:00Z", "TOT", "367", "0", "MAN", "360", "0", "pre"),
|
||||
]
|
||||
|
||||
_LEAGUE = {"afl": "afl", "baseball": "mlb", "basketball": "nba", "football": "nfl",
|
||||
"hockey": "nhl", "lacrosse": "ncaa_mens_lacrosse", "nrl": "nrl",
|
||||
"soccer": "eng.1"}
|
||||
|
||||
|
||||
def _extra_fixture_events():
|
||||
"""Every event in a ledmatrix-plugins checkout, when one is named."""
|
||||
raw = os.environ.get("LEDMATRIX_PLUGINS")
|
||||
if not raw:
|
||||
return []
|
||||
root = Path(raw)
|
||||
if (root / "plugins").is_dir():
|
||||
root = root / "plugins"
|
||||
rows = []
|
||||
for path in sorted(root.glob("*-scoreboard/test/fixtures/mock.json")):
|
||||
plugin = path.parts[-4].replace("-scoreboard", "")
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
for block in data.values():
|
||||
for ev in (block.get("events") or []) if isinstance(block, dict) else []:
|
||||
try:
|
||||
comp = ev["competitions"][0]
|
||||
sides = {c["homeAway"]: c for c in comp["competitors"]}
|
||||
rows.append((plugin, ev["date"],
|
||||
sides["home"]["team"]["abbreviation"],
|
||||
sides["home"]["team"]["id"], sides["home"].get("score"),
|
||||
sides["away"]["team"]["abbreviation"],
|
||||
sides["away"]["team"]["id"], sides["away"].get("score"),
|
||||
""))
|
||||
except (KeyError, IndexError, TypeError):
|
||||
continue
|
||||
return rows
|
||||
|
||||
|
||||
def _flat(row):
|
||||
plugin, start, ha, hid, hs, aa, aid, as_, _state = row
|
||||
return {
|
||||
"league": _LEAGUE.get(plugin, plugin),
|
||||
"home_abbr": ha, "home_id": hid, "home_score": hs,
|
||||
"away_abbr": aa, "away_id": aid, "away_score": as_,
|
||||
"start_time_utc": datetime.fromisoformat(start.replace("Z", "+00:00")),
|
||||
}
|
||||
|
||||
|
||||
def _with_nested(game):
|
||||
"""The scroll card's input: flat keys kept, nested team dicts added."""
|
||||
out = dict(game)
|
||||
for side in ("home", "away"):
|
||||
out[f"{side}_team"] = {"abbrev": game.get(f"{side}_abbr"),
|
||||
"id": game.get(f"{side}_id"),
|
||||
"score": game.get(f"{side}_score")}
|
||||
return out
|
||||
|
||||
|
||||
def _nested_only(game):
|
||||
out = {k: v for k, v in _with_nested(game).items()
|
||||
if not k.startswith(("home_abbr", "home_id", "home_score",
|
||||
"away_abbr", "away_id", "away_score"))}
|
||||
return out
|
||||
|
||||
|
||||
_ROWS = FIXTURE_EVENTS + _extra_fixture_events()
|
||||
FLAT_GAMES = [_flat(r) for r in _ROWS]
|
||||
EDGE_FLAT_GAMES = [
|
||||
# NRL: abbreviations are not unique, ids are.
|
||||
{"league": "nrl", "home_abbr": "NEW", "home_id": "4", "home_score": "20",
|
||||
"away_abbr": "NEW", "away_id": "12", "away_score": "10"},
|
||||
{"league": "nfl", "home_abbr": "KC", "away_abbr": "BUF"},
|
||||
{"league": "nfl", "home_abbr": "KC", "away_abbr": "BUF", "home_score": "", "away_score": ""},
|
||||
{"league": "nfl", "home_abbr": "KC", "away_abbr": "BUF", "home_score": "-", "away_score": "-"},
|
||||
{"league": "nfl", "home_abbr": "KC", "away_abbr": "BUF", "home_score": "5.0",
|
||||
"away_score": "2.0"},
|
||||
{"league": "nfl", "home_abbr": "KC", "home_id": 12, "away_abbr": "BUF", "away_id": 2,
|
||||
"home_score": 3, "away_score": 3},
|
||||
{"league": "nfl", "home_abbr": None, "away_abbr": "BUF", "home_score": "1",
|
||||
"away_score": "2"},
|
||||
{"league": "nfl", "home_abbr": " kc ", "away_abbr": "BUF", "home_score": "1",
|
||||
"away_score": "2"},
|
||||
]
|
||||
ALL_FLAT = FLAT_GAMES + EDGE_FLAT_GAMES
|
||||
SCROLL_SHAPED = [_with_nested(g) for g in ALL_FLAT]
|
||||
|
||||
|
||||
def _favorite_choices(game):
|
||||
"""Every way a favourites list can relate to this game."""
|
||||
out = [[], ["AP_TOP_25"], ["NOBODY"]]
|
||||
for side in ("home", "away"):
|
||||
for key in ("abbr", "id"):
|
||||
value = game.get(f"{side}_{key}")
|
||||
if value is not None:
|
||||
out.append([str(value)])
|
||||
out.append([" " + str(value).lower() + " "])
|
||||
if game.get("home_abbr") and game.get("away_abbr"):
|
||||
out.append([game["home_abbr"], game["away_abbr"]])
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hosts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _Host(SportsCoreSharedMixin):
|
||||
"""The mixin with just the state these helpers read."""
|
||||
|
||||
def __init__(self, config=None, favorites=None, tz=timezone.utc, fonts=None):
|
||||
self.config = config
|
||||
self.favorite_teams = favorites
|
||||
self.logger = LOG
|
||||
self.fonts = fonts or {}
|
||||
self._tz = tz
|
||||
|
||||
def _get_timezone(self):
|
||||
return self._tz
|
||||
|
||||
|
||||
#: The map seven of the eight scoreboards' sports.py declare over the mixin's
|
||||
#: default (football is the one that inherits the default).
|
||||
PLUGIN_ELEMENT_FOR_FONT = {
|
||||
"odds": "odds_text", "score": "score_text", "time": "period_text",
|
||||
"team": "team_name", "status": "status_text", "detail": "detail_text",
|
||||
"rank": "rank_text",
|
||||
}
|
||||
|
||||
|
||||
def _call(fn, *args):
|
||||
"""Result or the exception type, so a raise on one side is a difference."""
|
||||
try:
|
||||
return fn(*args)
|
||||
except Exception as exc: # noqa: BLE001 - the type is the result here
|
||||
return f"<raises {type(exc).__name__}>"
|
||||
|
||||
|
||||
def _mismatches(pairs):
|
||||
return [(label, a, b) for label, a, b in pairs if a != b]
|
||||
|
||||
|
||||
RESULT_COLOURS = [
|
||||
{"enabled": True},
|
||||
{"enabled": True, "win_color": [1, 2, 3], "loss_color": "123",
|
||||
"tie_color": [999, -1, "7"]},
|
||||
{"enabled": False},
|
||||
{},
|
||||
]
|
||||
|
||||
SCROLL_CARD_CONFIGS = [
|
||||
None, {}, {"scroll_card": None}, {"scroll_card": {}}, {"scroll_card": "notadict"},
|
||||
{"scroll_card": {"vs_text": "@", "date_format": "weekday", "time_format": "24h",
|
||||
"switch_date_format": "inherit"}},
|
||||
{"scroll_card": {"vs_text": None, "date_format": "day_first", "time_format": "12h",
|
||||
"switch_date_format": "inherit"}},
|
||||
{"scroll_card": {"vs_text": 7, "date_format": "numeric", "switch_date_format": "inherit"}},
|
||||
{"scroll_card": {"date_format": "numeric_day_first", "time_format": "24h",
|
||||
"switch_date_format": "inherit"}},
|
||||
{"scroll_card": {"date_format": "abbrev", "switch_date_format": "inherit"}},
|
||||
{"scroll_card": {"date_format": "bogus", "switch_date_format": "inherit"}},
|
||||
]
|
||||
TIMES = ["7:30 PM", "12:00 AM", "12:05pm", "7 PM", "13:00 PM", "TBD", "", None,
|
||||
"7:61 PM", "x:30 PM", " 9:05 am ", "12:00 PM", "0:15 AM"]
|
||||
DATES = ["9/19", "09-19", "13/19", "Sep 19", "", None, "9/19/2026", " 1/2 ", "0/5"]
|
||||
STARTS = [datetime(2026, 9, 19, 23, 30, tzinfo=timezone.utc), "2026-09-19T23:30Z",
|
||||
"2026-09-20T02:00:00+00:00", "garbage", None, "", datetime(2026, 1, 1)]
|
||||
TIMEZONES = ["America/New_York", "Australia/Sydney", "UTC", "Not/AZone", None]
|
||||
|
||||
PS = resolve_asset_path("assets/fonts/PressStart2P-Regular.ttf")
|
||||
F46 = resolve_asset_path("assets/fonts/4x6-font.ttf")
|
||||
|
||||
|
||||
def _font_sets():
|
||||
a, b, c = load_truetype(PS, 8), load_truetype(PS, 16), load_truetype(F46, 7)
|
||||
keys = ("odds", "score", "time", "team", "status", "detail", "rank")
|
||||
return {
|
||||
"distinct": {"score": a, "time": b, "team": c, "status": load_truetype(PS, 8),
|
||||
"detail": load_truetype(F46, 7), "rank": load_truetype(F46, 14),
|
||||
"odds": load_truetype(F46, 7)},
|
||||
"score+time share": {"score": a, "time": a, "team": c},
|
||||
"team+rank share": {"score": a, "time": b, "team": c, "rank": c},
|
||||
"odds+score share": {"score": a, "odds": a, "time": b},
|
||||
"all share": {k: a for k in keys},
|
||||
}
|
||||
|
||||
|
||||
def _partition(fonts):
|
||||
"""Which keys still share one face object -- what unsharing decides."""
|
||||
groups = {}
|
||||
for key, font in fonts.items():
|
||||
groups.setdefault(id(font), []).append(key)
|
||||
return sorted(sorted(keys) for keys in groups.values())
|
||||
|
||||
|
||||
def _faces(fonts):
|
||||
return {k: (getattr(f, "path", None), getattr(f, "size", None)) for k, f in fonts.items()}
|
||||
|
||||
|
||||
def _schema_dir(tmp_path, name, text):
|
||||
d = tmp_path / name
|
||||
d.mkdir()
|
||||
if text is not None:
|
||||
(d / "config_schema.json").write_text(text)
|
||||
return d
|
||||
|
||||
|
||||
SCHEMAS = {
|
||||
"good": json.dumps({"properties": {"customization": {"properties": {
|
||||
"score_text": {"properties": {"font_size": {"default": 10}}},
|
||||
"period_text": {"properties": {"font_size": {"default": 8}}},
|
||||
"detail_text": {"properties": {"font_size": {"default": "6"}}},
|
||||
"team_name": {"properties": {"font": {"default": "x"}}}}}}}),
|
||||
"bad_json": "{not json",
|
||||
"bad_default": json.dumps({"properties": {"customization": {"properties": {
|
||||
"score_text": {"properties": {"font_size": {"default": "big"}}}}}}}),
|
||||
"missing": None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Identical pairs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIdentical:
|
||||
"""Pairs that agree on every input. Keep it that way."""
|
||||
|
||||
def test_scroll_card_option(self):
|
||||
pairs = []
|
||||
for i, cfg in enumerate(SCROLL_CARD_CONFIGS):
|
||||
host = _Host(cfg)
|
||||
for key, default in itertools.product(
|
||||
("vs_text", "date_format", "time_format", "missing"), (None, "D", 0)):
|
||||
pairs.append((f"cfg{i} {key} {default!r}",
|
||||
_call(host._card_option, key, default),
|
||||
_call(C.scroll_card_option, cfg, key, default)))
|
||||
assert not _mismatches(pairs)
|
||||
|
||||
def test_vs_text(self):
|
||||
pairs = [(f"cfg{i}", _call(_Host(cfg)._vs_text), _call(C.vs_text, cfg))
|
||||
for i, cfg in enumerate(SCROLL_CARD_CONFIGS)]
|
||||
assert not _mismatches(pairs)
|
||||
|
||||
def test_format_game_time(self):
|
||||
pairs = [(f"cfg{i} {t!r}", _call(_Host(cfg)._format_game_time, t),
|
||||
_call(C.format_game_time, cfg, t))
|
||||
for (i, cfg), t in itertools.product(enumerate(SCROLL_CARD_CONFIGS), TIMES)]
|
||||
assert not _mismatches(pairs)
|
||||
|
||||
def test_coerce_rgb(self):
|
||||
values = [[1, 2, 3], (300, -4, "5"), "123", [1, 2], [1, 2, 3, 4], None, 42,
|
||||
{"r": 1, "g": 2, "b": 3}, ["a", 1, 2], [1.9, 2, 3], [None, 1, 2]]
|
||||
pairs = [(repr(v), _call(_Host._coerce_rgb, v, (4, 5, 6)),
|
||||
_call(C.coerce_rgb, v, (4, 5, 6))) for v in values]
|
||||
assert not _mismatches(pairs)
|
||||
|
||||
def test_crisp_size(self):
|
||||
names = ["PressStart2P-Regular.ttf", "4x6-font.ttf", "press_start", "four_by_six",
|
||||
"5by7.regular.ttf", "user.ttf", None]
|
||||
sizes = [None, 0, -3, 1, 4, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 20, 7.5, "8"]
|
||||
pairs = [(f"{n} {s!r}", _call(_Host._crisp_size, n, s), _call(C.crisp_size, n, s))
|
||||
for n, s in itertools.product(names, sizes)]
|
||||
assert not _mismatches(pairs)
|
||||
|
||||
def test_crisp_size_honours_a_hosts_own_tables(self):
|
||||
"""A class that declares extra faces keeps them through the wrapper."""
|
||||
cls = type("Extra", (_Host,), {"_FONT_PIXEL_GRID": {"extra.ttf": 5},
|
||||
"_FONT_NAME_ALIASES": {"x": "extra.ttf"}})
|
||||
assert cls._crisp_size("x", 12) == C.crisp_size("x", 12, {"x": "extra.ttf"},
|
||||
{"extra.ttf": 5}) == 10
|
||||
|
||||
def test_constant_tables(self):
|
||||
assert SportsCoreSharedMixin.FAVORITE_RESULT_COLOR_DEFAULTS == \
|
||||
C.FAVORITE_RESULT_COLOR_DEFAULTS
|
||||
assert SportsCoreSharedMixin._MONTH_ABBR == C.MONTH_ABBR
|
||||
assert SportsCoreSharedMixin._WEEKDAY_ABBR == C.WEEKDAY_ABBR
|
||||
assert SportsCoreSharedMixin._FONT_PIXEL_GRID == C.FONT_PIXEL_GRID
|
||||
assert SportsCoreSharedMixin._FONT_NAME_ALIASES == C.FONT_NAME_ALIASES
|
||||
|
||||
def test_constant_dicts_are_not_aliased(self):
|
||||
# Equal, but separate objects: a caller mutating one table (tests do)
|
||||
# must not reach into the other module.
|
||||
assert SportsCoreSharedMixin.FAVORITE_RESULT_COLOR_DEFAULTS is not \
|
||||
C.FAVORITE_RESULT_COLOR_DEFAULTS
|
||||
assert SportsCoreSharedMixin._FONT_PIXEL_GRID is not C.FONT_PIXEL_GRID
|
||||
assert SportsCoreSharedMixin._FONT_NAME_ALIASES is not C.FONT_NAME_ALIASES
|
||||
|
||||
@pytest.mark.parametrize("schema", sorted(SCHEMAS))
|
||||
def test_schema_font_size_and_resolve_font_size(self, tmp_path, schema):
|
||||
d = _schema_dir(tmp_path, schema, SCHEMAS[schema])
|
||||
host = type("H_" + schema, (_Host,), {"_PLUGIN_DIR": str(d)})()
|
||||
path = str(d / "config_schema.json")
|
||||
pairs = []
|
||||
for key in ("score_text", "period_text", "detail_text", "team_name", "nope", "", None):
|
||||
pairs.append((f"schema {key!r}", _call(host._schema_font_size, key),
|
||||
_call(C.schema_font_size, path, key)))
|
||||
for ec, name, size in itertools.product(
|
||||
(None, {}, {"font_size": 10}, {"font_size": "10"}, {"font_size": 11},
|
||||
{"font_size": "big"}, {"font_size": None}, {"font_size": 8.7}),
|
||||
("PressStart2P-Regular.ttf", "4x6-font.ttf", "press_start", "user.ttf"),
|
||||
(6, 8, 10, None)):
|
||||
pairs.append((f"resolve {key!r} {ec} {name} {size}",
|
||||
_call(host._resolve_font_size, ec, key, size, name),
|
||||
_call(C.resolve_font_size, path, ec, key, size, name)))
|
||||
assert not _mismatches(pairs)
|
||||
|
||||
@pytest.mark.parametrize("element_map", ["mixin default", "plugin sports.py"])
|
||||
def test_unshare_element_fonts_given_the_same_map(self, element_map):
|
||||
"""Same map in, same faces out. (The maps themselves differ; pinned below.)"""
|
||||
mapping = (SportsCoreSharedMixin._ELEMENT_FOR_FONT if element_map == "mixin default"
|
||||
else PLUGIN_ELEMENT_FOR_FONT)
|
||||
host = type("H", (_Host,), {"_ELEMENT_FOR_FONT": mapping})()
|
||||
for name, fonts in _font_sets().items():
|
||||
mine, theirs = dict(fonts), dict(fonts)
|
||||
host._unshare_element_fonts(mine)
|
||||
C.unshare_element_fonts(LOG, theirs, mapping)
|
||||
assert _partition(mine) == _partition(theirs), name
|
||||
assert _faces(mine) == _faces(theirs), name
|
||||
|
||||
def test_unshare_element_fonts_default_map_is_unchanged(self):
|
||||
"""Omitting the new argument keeps the card's own map."""
|
||||
for name, fonts in _font_sets().items():
|
||||
default, explicit = dict(fonts), dict(fonts)
|
||||
C.unshare_element_fonts(LOG, default)
|
||||
C.unshare_element_fonts(LOG, explicit, C.ELEMENT_FOR_FONT)
|
||||
assert _partition(default) == _partition(explicit), name
|
||||
|
||||
def test_format_game_date_when_both_read_the_same_setting_and_zone(self):
|
||||
"""With ``switch_date_format: inherit`` the scorebug reads the card's
|
||||
``date_format``; given the same zone the two then format identically."""
|
||||
pairs = []
|
||||
for (i, cfg), tzname in itertools.product(enumerate(SCROLL_CARD_CONFIGS[5:]),
|
||||
TIMEZONES):
|
||||
conf = dict(cfg, timezone=tzname) if tzname else dict(cfg)
|
||||
host = _Host(conf, tz=C.card_tzinfo(conf, LOG))
|
||||
for d, start in itertools.product(DATES, STARTS):
|
||||
game = {"start_time_utc": start} if start is not None else {}
|
||||
pairs.append((f"cfg{i} tz={tzname} {d!r} {start!r}",
|
||||
_call(host._format_game_date, d, game),
|
||||
_call(C.format_game_date, conf, LOG, d, game)))
|
||||
pairs.append((f"weekday cfg{i} tz={tzname} {start!r}",
|
||||
_call(host._weekday_for, game),
|
||||
_call(C.weekday_for, conf, LOG, game)))
|
||||
assert not _mismatches(pairs)
|
||||
|
||||
def test_format_game_date_honours_a_hosts_month_table(self):
|
||||
"""The scoreboards redeclare _MONTH_ABBR; the shared body must read it."""
|
||||
cls = type("Months", (_Host,), {"_MONTH_ABBR": tuple(f"M{i}" for i in range(1, 13))})
|
||||
host = cls({"scroll_card": {"switch_date_format": "abbrev"}})
|
||||
assert host._format_game_date("9/19") == "M9 19"
|
||||
|
||||
def test_favorite_result_on_the_games_the_scoreboards_build(self):
|
||||
"""Production shape: the extractor stamps ``favorite_teams`` (the
|
||||
manager's resolved list) on every game, and the manager holds the same
|
||||
list. On those games -- flat for switch mode, flat plus nested for the
|
||||
scroll card -- the two sides agree on every result and every colour."""
|
||||
pairs = []
|
||||
for game in ALL_FLAT:
|
||||
for favs in _favorite_choices(game):
|
||||
stamped = dict(game, favorite_teams=list(favs))
|
||||
for colours in RESULT_COLOURS:
|
||||
cfg = {"customization": {"favorite_result_colors": colours}}
|
||||
host = _Host(cfg, favorites=list(favs))
|
||||
pairs.append((f"{game} {favs}",
|
||||
_call(host._favorite_result, stamped),
|
||||
_call(C.favorite_result, cfg, _with_nested(stamped))))
|
||||
pairs.append((f"{game} {favs} {colours}",
|
||||
_call(host._recent_score_color, stamped, (9, 9, 9)),
|
||||
_call(C.recent_score_color, cfg, LOG,
|
||||
_with_nested(stamped), (9, 9, 9))))
|
||||
assert not _mismatches(pairs)
|
||||
# And the corpus is not vacuous: every verdict actually occurs.
|
||||
verdicts = {a for _, a, _ in pairs if isinstance(a, str) or a is None}
|
||||
assert {"win", "loss", "tie", None} <= verdicts
|
||||
|
||||
def test_side_is_favorite_on_flat_games(self):
|
||||
pairs = []
|
||||
for game in ALL_FLAT:
|
||||
for favs in _favorite_choices(game):
|
||||
fav_set = {str(f).strip().upper() for f in favs if str(f).strip()}
|
||||
for side in ("home", "away"):
|
||||
pairs.append((f"{game} {side} {fav_set}",
|
||||
_call(_Host._side_is_favorite, game, side, fav_set),
|
||||
_call(C.side_is_favorite, game, side, fav_set)))
|
||||
assert not _mismatches(pairs)
|
||||
|
||||
def test_nrl_collision_is_resolved_the_same_way_on_both_sides(self):
|
||||
"""The _favorite_key seam: NRL's "NEW" is two clubs. Neither helper
|
||||
calls the seam; both match abbreviation OR id, so an id favourite picks
|
||||
one club and an abbreviation favourite picks both (no verdict)."""
|
||||
game = EDGE_FLAT_GAMES[0]
|
||||
for favs, expected in ((["4"], "win"), (["12"], "loss"), (["NEW"], None)):
|
||||
host = _Host({}, favorites=favs)
|
||||
assert host._favorite_result(game) == expected
|
||||
assert C.favorite_result({"favorite_teams": favs}, game) == expected
|
||||
|
||||
def test_an_ambiguous_nrl_abbreviation_tints_on_both_sides(self):
|
||||
"""Agreed -- and at odds with NRL's own favourite rule.
|
||||
|
||||
NRL's resolver logs a shared abbreviation ("NEW") as an error and
|
||||
passes it through unchanged, and its _is_favorite_game matches ids
|
||||
only, so selection never treats "NEW" as a favourite. Both colour
|
||||
helpers match on abbreviation too, so both modes tint a Knights result
|
||||
(and a Warriors one) for a user who typed "NEW". Not a twin
|
||||
divergence; a seam neither helper consults.
|
||||
"""
|
||||
on = {"customization": {"favorite_result_colors": {"enabled": True}}}
|
||||
game = {"league": "3", "home_abbr": "NEW", "home_id": "4", "home_score": "20",
|
||||
"away_abbr": "MEL", "away_id": "12", "away_score": "10",
|
||||
"favorite_teams": ["NEW"]}
|
||||
cfg = dict(on, favorite_teams=["NEW"])
|
||||
assert _Host(cfg, favorites=["NEW"])._recent_score_color(game, (9, 9, 9)) \
|
||||
== C.recent_score_color(cfg, LOG, game, (9, 9, 9)) == (0, 255, 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pinned divergences -- owner decision pending. Edit deliberately.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPinnedDivergence:
|
||||
"""Each test pins one difference between the twins as it stands today.
|
||||
|
||||
None of these is changed by the consolidation that made the identical pairs
|
||||
wrappers: each one is a colour, a weekday or a font face that one display
|
||||
mode shows differently from the other, so choosing a side is a product
|
||||
decision. If you are here because one of these failed, you changed which
|
||||
side wins -- make sure that was the decision, then update the pin.
|
||||
"""
|
||||
|
||||
NESTED_WIN = {"league": "nhl",
|
||||
"home_team": {"abbrev": "TB", "score": "4"},
|
||||
"away_team": {"abbrev": "BOS", "score": "1"}}
|
||||
|
||||
def test_side_is_favorite_nested_payload(self):
|
||||
# DIVERGENCE: the mixin reads only the flat <side>_abbr / <side>_id keys;
|
||||
# the card also reads <side>_team.{abbrev,abbreviation,id}. Unreachable
|
||||
# from the scoreboards' own extractors (always flat), reachable from a
|
||||
# nested-only payload.
|
||||
assert _Host._side_is_favorite(self.NESTED_WIN, "home", {"TB"}) is False
|
||||
assert C.side_is_favorite(self.NESTED_WIN, "home", {"TB"}) is True
|
||||
|
||||
def test_favorite_result_nested_payload(self):
|
||||
# DIVERGENCE: follows from the one above, plus score source: the mixin
|
||||
# reads home_score/away_score only; the card prefers the nested score.
|
||||
host = _Host({}, favorites=["TB"])
|
||||
game = dict(self.NESTED_WIN, favorite_teams=["TB"])
|
||||
assert host._favorite_result(game) is None
|
||||
assert C.favorite_result({}, game) == "win"
|
||||
|
||||
def test_favorite_result_when_nested_and_flat_scores_disagree(self):
|
||||
# DIVERGENCE: same game, two score sources. The mixin uses the flat
|
||||
# score, the card the nested one. The renderers' normaliser only fills
|
||||
# a nested score that is missing, so this needs a payload that already
|
||||
# carried both.
|
||||
game = {"home_abbr": "TB", "away_abbr": "BOS", "home_score": "1",
|
||||
"away_score": "4", "home_team": {"abbrev": "TB", "score": "4"},
|
||||
"away_team": {"abbrev": "BOS", "score": "1"}, "favorite_teams": ["TB"]}
|
||||
assert _Host({}, favorites=["TB"])._favorite_result(game) == "loss"
|
||||
assert C.favorite_result({}, game) == "win"
|
||||
|
||||
def test_favorite_result_favourite_sources(self):
|
||||
# DIVERGENCE: where the favourites come from. The mixin reads only
|
||||
# self.favorite_teams (the manager's list, resolved at construction);
|
||||
# the card reads the game's stamped favorite_teams plus the config's
|
||||
# league block (or root). All eight scoreboards stamp the game, so in
|
||||
# production both see the same list -- this pins the hand-built case.
|
||||
game = {"league": "mlb", "home_abbr": "ATL", "away_abbr": "NYM",
|
||||
"home_score": "5", "away_score": "2"}
|
||||
on = {"customization": {"favorite_result_colors": {"enabled": True}}}
|
||||
# Host favourites only, nothing stamped, nothing in config:
|
||||
assert _Host(on, favorites=["ATL"])._favorite_result(game) == "win"
|
||||
assert C.favorite_result(on, game) is None
|
||||
# Config league block only, host list empty:
|
||||
cfg = dict(on, mlb={"favorite_teams": ["ATL"]})
|
||||
assert _Host(cfg, favorites=[])._favorite_result(game) is None
|
||||
assert C.favorite_result(cfg, game) == "win"
|
||||
# Stamped on the game only, host list empty:
|
||||
stamped = dict(game, favorite_teams=["ATL"])
|
||||
assert _Host(on, favorites=[])._favorite_result(stamped) is None
|
||||
assert C.favorite_result(on, stamped) == "win"
|
||||
# ...which is what reaches the colour:
|
||||
assert _Host(on, favorites=["ATL"])._recent_score_color(game, (9, 9, 9)) == (0, 255, 0)
|
||||
assert C.recent_score_color(on, LOG, game, (9, 9, 9)) == (9, 9, 9)
|
||||
|
||||
def test_weekday_zone_source(self):
|
||||
# DIVERGENCE, user-visible: the scorebug asks the plugin's
|
||||
# _get_timezone() (plugin setting -> global setting -> system zone);
|
||||
# the card reads only config["timezone"] and falls back to UTC. The
|
||||
# scoreboards' schemas default that key to "", and the scroll display
|
||||
# hands the renderer the plugin config, so a board that sets only the
|
||||
# global zone gets UTC weekdays in scroll mode: an evening kickoff in
|
||||
# New York is labelled with the next day.
|
||||
game = {"start_time_utc": "2026-09-20T00:30:00+00:00"} # Sat 20:30 EDT
|
||||
host = _Host({}, tz=ZoneInfo("America/New_York"))
|
||||
assert host._weekday_for(game) == "Sat"
|
||||
assert C.weekday_for({}, LOG, game) == "Sun"
|
||||
cfg = {"scroll_card": {"date_format": "weekday", "switch_date_format": "inherit"}}
|
||||
host = _Host(cfg, tz=ZoneInfo("America/New_York"))
|
||||
assert host._format_game_date("9/19", game) == "Sat Sep 19"
|
||||
assert C.format_game_date(cfg, LOG, "9/19", game) == "Sun Sep 19"
|
||||
|
||||
def test_weekday_out_of_range_start(self):
|
||||
# DIVERGENCE: the mixin catches OverflowError from astimezone() and
|
||||
# drops the weekday; the card lets it escape to its caller.
|
||||
game = {"start_time_utc": "9999-12-31T23:59:00+00:00"}
|
||||
sydney = {"timezone": "Australia/Sydney"}
|
||||
assert _Host(sydney, tz=ZoneInfo("Australia/Sydney"))._weekday_for(game) == ""
|
||||
with pytest.raises(OverflowError):
|
||||
C.weekday_for(sydney, LOG, game)
|
||||
|
||||
def test_date_format_setting(self):
|
||||
# DIVERGENCE BY DESIGN (documented on _switch_date_format): the scorebug
|
||||
# reads scroll_card.switch_date_format (default "numeric", the "9/19"
|
||||
# it has always drawn); the card reads scroll_card.date_format (default
|
||||
# "abbrev"). "inherit" opts the scorebug into the card's setting.
|
||||
assert _Host({})._format_game_date("9/19") == "9/19"
|
||||
assert C.format_game_date({}, LOG, "9/19") == "Sep 19"
|
||||
|
||||
def test_upcoming_centre_setting(self):
|
||||
# DIVERGENCE BY DESIGN: not a same-named twin, but the same question.
|
||||
# switch_upcoming_center defaults to "date_time"; the card's
|
||||
# upcoming_center to "vs". "inherit" opts the scorebug in.
|
||||
assert _Host({})._switch_upcoming_center() == "date_time"
|
||||
assert C.upcoming_center_mode({}) == "vs"
|
||||
cfg = {"scroll_card": {"switch_upcoming_center": "inherit"}}
|
||||
assert _Host(cfg)._switch_upcoming_center() == C.upcoming_center_mode(cfg) == "vs"
|
||||
|
||||
def test_element_for_font_maps(self):
|
||||
# DIVERGENCE: the element vocabulary. The mixin default says team_text
|
||||
# and has no rank/odds; the card says team_name and has rank but no
|
||||
# odds. Seven scoreboards override the mixin map in sports.py with
|
||||
# PLUGIN_ELEMENT_FOR_FONT (team_name, rank, odds); football inherits
|
||||
# the default, and its schema declares team_name, not team_text.
|
||||
assert SportsCoreSharedMixin._ELEMENT_FOR_FONT == {
|
||||
"score": "score_text", "time": "period_text", "team": "team_text",
|
||||
"detail": "detail_text", "status": "status_text"}
|
||||
assert C.ELEMENT_FOR_FONT == {
|
||||
"score": "score_text", "time": "period_text", "team": "team_name",
|
||||
"status": "status_text", "detail": "detail_text", "rank": "rank_text"}
|
||||
|
||||
def test_font_color_team_element(self):
|
||||
# DIVERGENCE (consequence of the maps): a colour set on team_name
|
||||
# reaches the card's team face but not the mixin-default one.
|
||||
team = load_truetype(F46, 7)
|
||||
fonts = {"score": load_truetype(PS, 8), "team": team}
|
||||
cfg = {"customization": {"team_name": {"text_color": [1, 1, 1]}}}
|
||||
assert _Host(cfg, fonts=fonts)._font_color(team, (7, 7, 7)) == (7, 7, 7)
|
||||
assert C.font_color(cfg, fonts, team, (7, 7, 7)) == (1, 1, 1)
|
||||
plugin_host = type("P", (_Host,), {"_ELEMENT_FOR_FONT": PLUGIN_ELEMENT_FOR_FONT})
|
||||
assert plugin_host(cfg, fonts=fonts)._font_color(team, (7, 7, 7)) == (1, 1, 1)
|
||||
|
||||
def test_unshare_element_fonts_odds_face(self):
|
||||
# DIVERGENCE (consequence of the maps): the scoreboards' sports.py map
|
||||
# includes "odds", so switch mode gives the odds face its own object;
|
||||
# the card's map has no "odds", so scroll mode leaves it sharing the
|
||||
# score's face (and _card.font_color then colours it as score_text).
|
||||
shared = load_truetype(PS, 8)
|
||||
mine = {"score": shared, "odds": shared}
|
||||
theirs = dict(mine)
|
||||
type("P", (_Host,), {"_ELEMENT_FOR_FONT": PLUGIN_ELEMENT_FOR_FONT})() \
|
||||
._unshare_element_fonts(mine)
|
||||
C.unshare_element_fonts(LOG, theirs)
|
||||
assert mine["odds"] is not mine["score"]
|
||||
assert theirs["odds"] is theirs["score"]
|
||||
|
||||
def test_schema_default_cache_lifetimes(self, tmp_path):
|
||||
# DELIBERATE, and the reason there are still two caches: the mixin
|
||||
# caches per class, the card per schema path. The display service
|
||||
# builds new classes when it reloads a plugin, so switch mode picks up
|
||||
# an edited schema then; the card's module-level cache does not. One
|
||||
# shared cache would change what switch mode does after a reload.
|
||||
d = _schema_dir(tmp_path, "reload", SCHEMAS["good"])
|
||||
path = str(d / "config_schema.json")
|
||||
first = type("First", (_Host,), {"_PLUGIN_DIR": str(d)})()
|
||||
assert first._schema_font_size("score_text") == 10
|
||||
assert C.schema_font_size(path, "score_text") == 10
|
||||
(d / "config_schema.json").write_text(SCHEMAS["good"].replace("10", "16"))
|
||||
reloaded = type("Reloaded", (_Host,), {"_PLUGIN_DIR": str(d)})()
|
||||
assert reloaded._schema_font_size("score_text") == 16
|
||||
assert first._schema_font_size("score_text") == 10
|
||||
assert C.schema_font_size(path, "score_text") == 10
|
||||
|
||||
def test_element_color_mode(self):
|
||||
# DIVERGENCE at the call site, not in a body: both resolve through
|
||||
# src.element_style, but the mixin passes the instance's SKIN_MODE
|
||||
# ("live"/"recent"/"upcoming", set by all eight scoreboards) and the
|
||||
# renderers call _card.element_color with no mode, so a per-mode colour
|
||||
# override applies in switch mode only.
|
||||
cfg = {"customization": {"score_text": {"text_color": [255, 0, 0]},
|
||||
"modes": {"recent": {"score_text": {"text_color": [0, 0, 255]}}}}}
|
||||
host = _Host(cfg)
|
||||
host.SKIN_MODE = "recent"
|
||||
assert host._element_color("score_text") == (0, 0, 255)
|
||||
assert C.element_color(cfg, "score_text") == (255, 0, 0)
|
||||
@@ -37,6 +37,9 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||
INSTALLERS = (
|
||||
ROOT / "first_time_install.sh",
|
||||
ROOT / "scripts" / "install" / "configure_wifi_permissions.sh",
|
||||
# The ledmatrix_web rules, which first_time_install.sh and
|
||||
# configure_web_sudo.sh both take from here.
|
||||
ROOT / "scripts" / "install" / "lib_sudoers.sh",
|
||||
)
|
||||
|
||||
#: Commands this change grants, each fully literal in the source.
|
||||
|
||||
@@ -62,33 +62,135 @@ def test_configure_web_sudo_validates_before_installing():
|
||||
assert validate < install, "the rules must be checked before they are installed"
|
||||
|
||||
|
||||
def _render_first_time_sudoers(project_root, user):
|
||||
"""Run the installer's own sudoers heredoc with realistic values."""
|
||||
def test_a_missing_rules_library_installs_nothing():
|
||||
"""If lib_sudoers.sh is missing, nothing is generated -- and an empty file
|
||||
would pass `visudo -c` -- so that branch must set the flag the install is
|
||||
gated on."""
|
||||
body = _read(FIRST_TIME)
|
||||
start = body.index("# Create sudoers content")
|
||||
missing = body.index('if [ -f "$SUDOERS_LIB" ]; then')
|
||||
flagged = body.index("SUDOERS_VALID=0", missing)
|
||||
validate = body.index('visudo -c -f "$SUDOERS_TMP"')
|
||||
install = body.index('cp "$SUDOERS_TMP" "$SUDOERS_FILE"')
|
||||
gate = body.rindex('if [ "$SUDOERS_VALID" = "0" ]; then', 0, install)
|
||||
assert missing < flagged < validate < gate < install
|
||||
|
||||
|
||||
def _step10_generation(body):
|
||||
"""first_time_install.sh's own Step 10 code that writes $SUDOERS_TMP."""
|
||||
start = body.index("# The rules themselves live in scripts/install/lib_sudoers.sh")
|
||||
end = body.index("# Never install rules we have not parsed.")
|
||||
block = body[start:end]
|
||||
out = os.path.join(project_root, "rendered")
|
||||
return body[start:end]
|
||||
|
||||
|
||||
def _run_step10_generation(project_root, user, out):
|
||||
"""Run the installer's Step 10 generation with realistic values.
|
||||
|
||||
Returns the SUDOERS_VALID it leaves behind."""
|
||||
script = "\n".join(
|
||||
[
|
||||
"set -euo pipefail",
|
||||
"set -Eeuo pipefail",
|
||||
f"ACTUAL_USER={user}",
|
||||
f"PROJECT_ROOT_DIR={project_root}",
|
||||
'SUDOERS_TMP="$(mktemp)"',
|
||||
"PYTHON_PATH=$(which python3)",
|
||||
f"PROJECT_ROOT_DIR='{project_root}'",
|
||||
f"SUDOERS_TMP='{out}'",
|
||||
"SUDOERS_FILE=/etc/sudoers.d/ledmatrix_web",
|
||||
"SYSTEMCTL_PATH=/usr/bin/systemctl",
|
||||
"REBOOT_PATH=/usr/sbin/reboot",
|
||||
"POWEROFF_PATH=/usr/sbin/poweroff",
|
||||
"BASH_PATH=$(which bash)",
|
||||
"JOURNALCTL_PATH=/usr/bin/journalctl",
|
||||
block,
|
||||
f'cp "$SUDOERS_TMP" {out}',
|
||||
_step10_generation(_read(FIRST_TIME)),
|
||||
'printf %s "$SUDOERS_VALID"',
|
||||
]
|
||||
)
|
||||
subprocess.run(["bash", "-c", script], check=True)
|
||||
return subprocess.run(
|
||||
["bash", "-c", script], check=True, capture_output=True, text=True
|
||||
).stdout
|
||||
|
||||
|
||||
def _render_first_time_sudoers(tmp, user):
|
||||
"""The rules first_time_install.sh generates, via the shared library."""
|
||||
out = os.path.join(tmp, "rendered")
|
||||
assert _run_step10_generation(REPO_ROOT, user, out) == "1"
|
||||
return out
|
||||
|
||||
|
||||
def _run_step10(tmp, project_root, visudo_ok, existing=None):
|
||||
"""Run all of Step 10 against a sudoers file in `tmp`, never /etc.
|
||||
|
||||
systemctl, reboot, poweroff, journalctl and visudo are stubs, so the
|
||||
outcome does not depend on the machine running the test."""
|
||||
body = _read(FIRST_TIME)
|
||||
step = body[body.index('CURRENT_STEP="Configure passwordless sudo access"'):
|
||||
body.index('CURRENT_STEP="Configure WiFi management permissions"')]
|
||||
target = os.path.join(tmp, "ledmatrix_web")
|
||||
real = 'SUDOERS_FILE="/etc/sudoers.d/ledmatrix_web"'
|
||||
assert step.count(real) == 1
|
||||
step = step.replace(real, f"SUDOERS_FILE='{target}'")
|
||||
stubs = os.path.join(tmp, "stubs")
|
||||
os.mkdir(stubs)
|
||||
for name, code in (("systemctl", 0), ("reboot", 0), ("poweroff", 0),
|
||||
("journalctl", 0), ("visudo", 0 if visudo_ok else 1)):
|
||||
path = os.path.join(stubs, name)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
handle.write(f"#!/bin/sh\nexit {code}\n")
|
||||
os.chmod(path, 0o755)
|
||||
if existing is not None:
|
||||
with open(target, "w", encoding="utf-8") as handle:
|
||||
handle.write(existing)
|
||||
env = dict(os.environ, TMPDIR=tmp,
|
||||
PATH=os.pathsep.join([stubs, os.path.dirname(sys.executable),
|
||||
"/usr/bin", "/bin"]))
|
||||
script = "\n".join(["set -Eeuo pipefail", "ACTUAL_USER=ledmatrix",
|
||||
f"PROJECT_ROOT_DIR='{project_root}'", step])
|
||||
result = subprocess.run(["bash", "-c", script], env=env,
|
||||
capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
return target, stubs, result
|
||||
|
||||
|
||||
_POSIX_STEP10 = pytest.mark.skipif(
|
||||
sys.platform == "win32" or shutil.which("which") is None,
|
||||
reason="needs a POSIX bash and which")
|
||||
|
||||
|
||||
@_POSIX_STEP10
|
||||
def test_step10_installs_the_generated_rules():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target, stubs, _ = _run_step10(tmp, REPO_ROOT, visudo_ok=True)
|
||||
assert oct(os.stat(target).st_mode & 0o777) == "0o440"
|
||||
with open(target, encoding="utf-8") as handle:
|
||||
installed = handle.read()
|
||||
lib = os.path.join(REPO_ROOT, "scripts", "install", "lib_sudoers.sh")
|
||||
expected = subprocess.run(
|
||||
["bash", "-c", '. "$1"; web_sudoers_rules ledmatrix "$2" "$3/systemctl" '
|
||||
'"$(command -v bash)" "$3/reboot" "$3/poweroff" "$3/journalctl"',
|
||||
"_", lib, REPO_ROOT, stubs],
|
||||
check=True, capture_output=True, text=True,
|
||||
env=dict(os.environ, PATH=os.pathsep.join([stubs, "/usr/bin", "/bin"])),
|
||||
).stdout
|
||||
assert installed == expected
|
||||
assert not [f for f in os.listdir(tmp) if f.startswith("ledmatrix_web_sudoers.")]
|
||||
|
||||
|
||||
@_POSIX_STEP10
|
||||
def test_step10_without_the_library_keeps_the_existing_file():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target, _, result = _run_step10(tmp, tmp, visudo_ok=True, existing="keep\n")
|
||||
with open(target, encoding="utf-8") as handle:
|
||||
assert handle.read() == "keep\n"
|
||||
assert "lib_sudoers.sh not found" in result.stderr
|
||||
assert "Passwordless sudo access configured" not in result.stdout
|
||||
|
||||
|
||||
@_POSIX_STEP10
|
||||
def test_step10_keeps_the_existing_file_when_the_rules_do_not_parse():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
target, _, result = _run_step10(tmp, REPO_ROOT, visudo_ok=False, existing="keep\n")
|
||||
with open(target, encoding="utf-8") as handle:
|
||||
assert handle.read() == "keep\n"
|
||||
assert "did not parse" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="visudo is POSIX only")
|
||||
@pytest.mark.skipif(VISUDO is None, reason="visudo not installed")
|
||||
def test_the_rules_the_installer_emits_actually_parse():
|
||||
|
||||
@@ -30,10 +30,14 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||
INSTALLERS = (
|
||||
ROOT / "first_time_install.sh",
|
||||
ROOT / "scripts" / "install" / "configure_wifi_permissions.sh",
|
||||
# Writes the same journalctl grants as first_time_install.sh. It was
|
||||
# missing here, and because of that this suite passed while three
|
||||
# ungranted wildcard rules sat in it.
|
||||
# Used to write its own copy of the journalctl grants. It was missing
|
||||
# here, and because of that this suite passed while three untagged
|
||||
# wildcard rules sat in it. Both it and first_time_install.sh now take
|
||||
# their rules from lib_sudoers.sh; they stay listed so a rule written
|
||||
# directly into either one is still checked.
|
||||
ROOT / "scripts" / "install" / "configure_web_sudo.sh",
|
||||
# The ledmatrix_web rules, shared by both installers.
|
||||
ROOT / "scripts" / "install" / "lib_sudoers.sh",
|
||||
)
|
||||
|
||||
#: Commands that will start another program of their own accord -- a pager, an
|
||||
|
||||
@@ -230,6 +230,52 @@ class TestHandlersCarryDetail:
|
||||
"handlers returning the generic message without %s: %r"
|
||||
% ("both a traceback log and the detail", offenders))
|
||||
|
||||
def test_no_api_v3_route_copies_the_blueprint_handler(self):
|
||||
"""The generic catch-all lives once, on the blueprint.
|
||||
|
||||
Fifty-three routes carried their own copy of it -- log with exc_info,
|
||||
return {status, "An error occurred; see logs for details",
|
||||
describe_exception(e)}, 500 -- until they were folded into
|
||||
`_api_v3_unhandled_exception`. A new copy changes nothing a caller
|
||||
sees, so nothing else would notice it; this does. A handler that says
|
||||
something *different* (its own message, extra keys, cleanup) is fine.
|
||||
"""
|
||||
import ast
|
||||
import pathlib
|
||||
|
||||
generic = "An error occurred; see logs for details"
|
||||
copies = []
|
||||
for path in sorted(pathlib.Path("web_interface/blueprints/api_v3").glob("*.py")):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for fn in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]:
|
||||
for h in ast.walk(fn):
|
||||
if not (isinstance(h, ast.ExceptHandler)
|
||||
and isinstance(h.type, ast.Name)
|
||||
and h.type.id == "Exception"):
|
||||
continue
|
||||
for r in [n for n in h.body if isinstance(n, ast.Return)]:
|
||||
v = r.value
|
||||
if not (isinstance(v, ast.Tuple) and len(v.elts) == 2
|
||||
and isinstance(v.elts[0], ast.Call)
|
||||
and getattr(v.elts[0].func, "id", None) == "jsonify"
|
||||
and v.elts[0].args
|
||||
and isinstance(v.elts[0].args[0], ast.Dict)):
|
||||
continue
|
||||
d = v.elts[0].args[0]
|
||||
keys = {k.value for k in d.keys if isinstance(k, ast.Constant)}
|
||||
message = [val.value for k, val in zip(d.keys, d.values)
|
||||
if isinstance(k, ast.Constant) and k.value == "message"
|
||||
and isinstance(val, ast.Constant)]
|
||||
if keys == {"status", "message", "details"} and message == [generic]:
|
||||
copies.append((path.name, fn.name))
|
||||
|
||||
# One is not a copy: execute_plugin_action's step-1 handler sits
|
||||
# inside the route's `except subprocess.TimeoutExpired` arm, which
|
||||
# would turn a plugin's own timeout into a 408 if this let it through.
|
||||
assert copies == [("plugins.py", "execute_plugin_action")], (
|
||||
"these handlers duplicate the api_v3 blueprint's errorhandler; "
|
||||
"delete them and let the exception propagate: %r" % copies)
|
||||
|
||||
def test_client_errors_keep_their_own_status(self):
|
||||
"""A 405 must not be reported as a server-side UNKNOWN_ERROR.
|
||||
|
||||
|
||||
@@ -1,53 +1,189 @@
|
||||
"""The two installers that write /etc/sudoers.d/ledmatrix_web must agree.
|
||||
"""One generator writes /etc/sudoers.d/ledmatrix_web, and both installers use it.
|
||||
|
||||
first_time_install.sh (Step 10, a heredoc) and scripts/install/configure_web_sudo.sh
|
||||
(a block of echo lines) each generate the web user's sudo allow-list. They
|
||||
drifted: configure_web_sudo.sh granted scripts/fix_perms/safe_pip_install.sh
|
||||
but first_time_install.sh did not, so on a device set up only by the first-time
|
||||
first_time_install.sh (Step 10) and scripts/install/configure_web_sudo.sh each
|
||||
used to carry their own copy of the web user's sudo allow-list -- a heredoc in
|
||||
one, a block of echo lines in the other -- and the copies drifted:
|
||||
configure_web_sudo.sh granted scripts/fix_perms/safe_pip_install.sh but
|
||||
first_time_install.sh did not, so on a device set up only by the first-time
|
||||
installer permission_utils.install_requirements_file could not use the root
|
||||
wrapper and fell back to a user-level install that root-run ledmatrix.service
|
||||
may not see (and the auto-update rollback reported its reinstall as failed).
|
||||
|
||||
This compares the granted command sets after normalising the spellings that
|
||||
differ between the files but expand identically at install time:
|
||||
$WEB_USER/$ACTUAL_USER, $PROJECT_ROOT/$PROJECT_ROOT_DIR, and the helper-path
|
||||
variables configure_web_sudo.sh defines ($SAFE_RM_PATH, ...).
|
||||
The rules now live once, in web_sudoers_rules() in
|
||||
scripts/install/lib_sudoers.sh. What keeps them from drifting again:
|
||||
|
||||
* neither installer writes a rule line of its own, and each writes the
|
||||
generator's output to the very file it then validates and installs;
|
||||
* each passes its variables to the generator in the right positions -- checked
|
||||
by running the installer's own call line with distinct values;
|
||||
* the generator's grants are pinned to an explicit list below, so dropping,
|
||||
adding or re-pathing a grant is a deliberate edit to this file.
|
||||
|
||||
It also checks that every fix_perms helper granted via sudo is hardened to
|
||||
root:root in both scripts -- and, in first_time_install.sh, after Step 11's
|
||||
root:root in both installers -- and, in first_time_install.sh, after Step 11's
|
||||
project-wide chown to the user, which would otherwise undo it.
|
||||
"""
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
FIRST_TIME = ROOT / "first_time_install.sh"
|
||||
CONFIGURE = ROOT / "scripts" / "install" / "configure_web_sudo.sh"
|
||||
LIB = ROOT / "scripts" / "install" / "lib_sudoers.sh"
|
||||
|
||||
#: Grants that intentionally exist in only one installer, as normalised
|
||||
#: commands. There are none today; add one here with a reason rather than
|
||||
#: loosening the comparison.
|
||||
ONLY_IN_FIRST_TIME = frozenset()
|
||||
ONLY_IN_CONFIGURE = frozenset()
|
||||
#: Every grant web_sudoers_rules() writes, as (tags, command) with the
|
||||
#: generator's own variable names. Changing the allow-list means changing this.
|
||||
EXPECTED_GRANTS = frozenset({
|
||||
("NOPASSWD:", "$REBOOT_PATH"),
|
||||
("NOPASSWD:", "$POWEROFF_PATH"),
|
||||
("NOPASSWD:", "$SYSTEMCTL_PATH start ledmatrix.service"),
|
||||
("NOPASSWD:", "$SYSTEMCTL_PATH stop ledmatrix.service"),
|
||||
("NOPASSWD:", "$SYSTEMCTL_PATH restart ledmatrix.service"),
|
||||
("NOPASSWD:", "$SYSTEMCTL_PATH enable ledmatrix.service"),
|
||||
("NOPASSWD:", "$SYSTEMCTL_PATH disable ledmatrix.service"),
|
||||
("NOPASSWD:", "$SYSTEMCTL_PATH status ledmatrix.service"),
|
||||
("NOPASSWD:", "$SYSTEMCTL_PATH is-active ledmatrix"),
|
||||
("NOPASSWD:", "$SYSTEMCTL_PATH is-active ledmatrix.service"),
|
||||
("NOPASSWD:", "$SYSTEMCTL_PATH start ledmatrix-web.service"),
|
||||
("NOPASSWD:", "$SYSTEMCTL_PATH stop ledmatrix-web.service"),
|
||||
("NOPASSWD:", "$SYSTEMCTL_PATH restart ledmatrix-web.service"),
|
||||
("NOPASSWD:", "$BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh *"),
|
||||
("NOPASSWD:", "$BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh *"),
|
||||
("NOPASSWD:NOEXEC:", "$JOURNALCTL_PATH -u ledmatrix.service *"),
|
||||
("NOPASSWD:NOEXEC:", "$JOURNALCTL_PATH -u ledmatrix *"),
|
||||
("NOPASSWD:NOEXEC:", "$JOURNALCTL_PATH -t ledmatrix *"),
|
||||
})
|
||||
|
||||
#: The call each installer makes: its own names for the generator's arguments,
|
||||
#: in order, and the file it writes the rules to.
|
||||
CALLERS = {
|
||||
FIRST_TIME: (("$ACTUAL_USER", "$PROJECT_ROOT_DIR", "$SYSTEMCTL_PATH", "$BASH_PATH",
|
||||
"$REBOOT_PATH", "$POWEROFF_PATH", "$JOURNALCTL_PATH"), "$SUDOERS_TMP"),
|
||||
CONFIGURE: (("$WEB_USER", "$PROJECT_ROOT", "$SYSTEMCTL_PATH", "$BASH_PATH",
|
||||
"$REBOOT_PATH", "$POWEROFF_PATH", "$JOURNALCTL_PATH"), "$TEMP_SUDOERS"),
|
||||
}
|
||||
|
||||
RULE = re.compile(r'(\S+) ALL=\(ALL\) (NOPASSWD:(?:NOEXEC:)?)\s*(.*?)"?$')
|
||||
|
||||
|
||||
def _text(path):
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
return path.read_text(encoding="utf-8", errors="replace").replace("\r\n", "\n")
|
||||
|
||||
|
||||
def _web_sudoers_section(path):
|
||||
"""The part of the script that writes the ledmatrix_web allow-list.
|
||||
def _generator_grants():
|
||||
"""{(tags, command)} for every rule line in lib_sudoers.sh."""
|
||||
grants = set()
|
||||
for line in _text(LIB).splitlines():
|
||||
m = RULE.search(line.strip())
|
||||
if m and m.group(1).endswith("$WEB_USER"):
|
||||
grants.add((m.group(2), " ".join(m.group(3).split())))
|
||||
return grants
|
||||
|
||||
first_time_install.sh also writes other files later (WiFi permissions are
|
||||
delegated to a separate script, but keep this robust against future
|
||||
additions), so restrict it to Step 10.
|
||||
"""
|
||||
|
||||
def _call(path):
|
||||
"""The installer's web_sudoers_rules statement, continuation lines joined."""
|
||||
text = _text(path)
|
||||
if path == FIRST_TIME:
|
||||
start = text.index('CURRENT_STEP="Configure passwordless sudo access"')
|
||||
end = text.index('CURRENT_STEP="Configure WiFi management permissions"')
|
||||
return text[start:end]
|
||||
return text
|
||||
calls = re.findall(r"^[ \t]*web_sudoers_rules\b(?:[^\n]*\\\n)*[^\n]*$", text, re.M)
|
||||
assert len(calls) == 1, f"{path.name}: expected one web_sudoers_rules call, found {calls}"
|
||||
return calls[0]
|
||||
|
||||
|
||||
def test_generator_grants_exactly_the_expected_rules():
|
||||
grants = _generator_grants()
|
||||
assert grants == EXPECTED_GRANTS, (
|
||||
f"lib_sudoers.sh grants changed:\n added: {sorted(grants - EXPECTED_GRANTS)}\n"
|
||||
f" removed: {sorted(EXPECTED_GRANTS - grants)}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("installer", [FIRST_TIME, CONFIGURE], ids=lambda p: p.name)
|
||||
def test_installer_writes_no_rules_of_its_own(installer):
|
||||
"""A rule added to one installer only is how they drifted last time."""
|
||||
own = [line for line in _text(installer).splitlines()
|
||||
if "NOPASSWD" in line and not line.lstrip().startswith("#")]
|
||||
assert not own, f"{installer.name} writes sudoers rules itself: {own}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("installer", [FIRST_TIME, CONFIGURE], ids=lambda p: p.name)
|
||||
def test_installer_sources_the_generator_and_writes_what_it_validates(installer):
|
||||
text = _text(installer)
|
||||
assert "lib_sudoers.sh" in text, f"{installer.name} does not source lib_sudoers.sh"
|
||||
args, target = CALLERS[installer]
|
||||
call = _call(installer)
|
||||
words = call.replace("\\\n", " ").split()
|
||||
assert words[0] == "web_sudoers_rules"
|
||||
assert tuple(w.strip('"') for w in words[1:8]) == args, (
|
||||
f"{installer.name} passes the generator's arguments out of order: {call}")
|
||||
assert words[8:] == [">", f'"{target}"'], call
|
||||
# ...and that file is the one it runs visudo on.
|
||||
assert f'visudo -c -f "{target}"' in text
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32" or shutil.which("bash") is None,
|
||||
reason="needs a POSIX bash")
|
||||
@pytest.mark.parametrize("installer", [FIRST_TIME, CONFIGURE], ids=lambda p: p.name)
|
||||
def test_installer_call_renders_the_expected_rules(installer, tmp_path):
|
||||
"""Run the installer's own call line, with a distinct value per argument."""
|
||||
args, target = CALLERS[installer]
|
||||
values = {
|
||||
args[0]: "webuser", args[1]: "/srv/led root", args[2]: "/x/systemctl",
|
||||
args[3]: "/x/bash", args[4]: "/x/reboot", args[5]: "/x/poweroff",
|
||||
args[6]: "/x/journalctl", target: str(tmp_path / "out"),
|
||||
}
|
||||
assigns = "\n".join(f"{name[1:]}='{value}'" for name, value in values.items())
|
||||
script = f"set -euo pipefail\n. '{LIB}'\n{assigns}\n{_call(installer)}\n"
|
||||
subprocess.run(["bash", "-c", script], check=True)
|
||||
rendered = set()
|
||||
for line in (tmp_path / "out").read_text(encoding="utf-8").splitlines():
|
||||
m = RULE.match(line)
|
||||
if m:
|
||||
assert m.group(1) == "webuser", line
|
||||
rendered.add((m.group(2), m.group(3)))
|
||||
subst = {"$SYSTEMCTL_PATH": "/x/systemctl", "$BASH_PATH": "/x/bash",
|
||||
"$REBOOT_PATH": "/x/reboot", "$POWEROFF_PATH": "/x/poweroff",
|
||||
"$JOURNALCTL_PATH": "/x/journalctl", "$PROJECT_ROOT": "/srv/led root"}
|
||||
expected = set()
|
||||
for tags, command in EXPECTED_GRANTS:
|
||||
for var, value in subst.items():
|
||||
command = command.replace(var, value)
|
||||
expected.add((tags, command))
|
||||
assert rendered == expected
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32" or shutil.which("bash") is None,
|
||||
reason="needs a POSIX bash")
|
||||
def test_optional_tools_are_left_out_when_absent(tmp_path):
|
||||
"""configure_web_sudo.sh passes "" for a missing reboot/poweroff/journalctl.
|
||||
|
||||
An empty path would otherwise leave `user ALL=(ALL) NOPASSWD: ` behind,
|
||||
which visudo rejects, and the whole file would not be installed.
|
||||
"""
|
||||
out = subprocess.run(
|
||||
["bash", "-c", f". '{LIB}'; web_sudoers_rules u /p /bin/systemctl /bin/bash '' '' ''"],
|
||||
check=True, capture_output=True, text=True).stdout
|
||||
rules = [line for line in out.splitlines() if RULE.match(line)]
|
||||
assert len(rules) == len(EXPECTED_GRANTS) - 5
|
||||
assert not [r for r in rules if r.rstrip().endswith("NOPASSWD:")]
|
||||
assert "journalctl" not in out
|
||||
|
||||
|
||||
def test_pip_install_helper_is_granted():
|
||||
wanted = ("NOPASSWD:", "$BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh *")
|
||||
assert wanted in _generator_grants()
|
||||
|
||||
|
||||
def _granted_helpers():
|
||||
helpers = set()
|
||||
for _, command in _generator_grants():
|
||||
m = re.search(r"scripts/fix_perms/([\w.-]+\.sh)", command)
|
||||
if m:
|
||||
helpers.add(m.group(1))
|
||||
assert helpers, "no fix_perms helper grant found; the parser matched nothing"
|
||||
return helpers
|
||||
|
||||
|
||||
def _variables(text):
|
||||
@@ -60,57 +196,9 @@ def _normalise(command, variables):
|
||||
for _ in range(3): # helper paths reference $PROJECT_ROOT
|
||||
command = re.sub(r"\$\{?([A-Z][A-Z0-9_]*)\}?",
|
||||
lambda m: variables.get(m.group(1), m.group(0)), command)
|
||||
command = command.replace("$PROJECT_ROOT_DIR", "$PROJECT_ROOT")
|
||||
return " ".join(command.split())
|
||||
|
||||
|
||||
def _grants(path):
|
||||
"""{(tags, command)} for every ledmatrix_web rule the script writes."""
|
||||
section = _web_sudoers_section(path)
|
||||
variables = _variables(_text(path))
|
||||
grants = set()
|
||||
for line in section.splitlines():
|
||||
m = re.search(r'\$(?:WEB_USER|ACTUAL_USER) ALL=\(ALL\) (NOPASSWD:(?:NOEXEC:)?)\s*(.*)$',
|
||||
line)
|
||||
if not m:
|
||||
continue
|
||||
command = m.group(2).rstrip().rstrip('"').rstrip()
|
||||
grants.add((m.group(1), _normalise(command, variables)))
|
||||
return grants
|
||||
|
||||
|
||||
def test_both_installers_generate_rules():
|
||||
# Guards against the parser silently matching nothing in either file.
|
||||
assert len(_grants(FIRST_TIME)) >= 15
|
||||
assert len(_grants(CONFIGURE)) >= 15
|
||||
|
||||
|
||||
def test_installers_grant_the_same_commands():
|
||||
first = _grants(FIRST_TIME)
|
||||
configure = _grants(CONFIGURE)
|
||||
only_first = {c for c in first - configure if c[1] not in ONLY_IN_FIRST_TIME}
|
||||
only_configure = {c for c in configure - first if c[1] not in ONLY_IN_CONFIGURE}
|
||||
assert not only_first and not only_configure, (
|
||||
"ledmatrix_web sudoers drift between installers:\n"
|
||||
f" only in first_time_install.sh: {sorted(only_first)}\n"
|
||||
f" only in configure_web_sudo.sh: {sorted(only_configure)}")
|
||||
|
||||
|
||||
def test_pip_install_helper_is_granted():
|
||||
wanted = ("NOPASSWD:", "$BASH_PATH $PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh *")
|
||||
assert wanted in _grants(FIRST_TIME)
|
||||
assert wanted in _grants(CONFIGURE)
|
||||
|
||||
|
||||
def _granted_helpers():
|
||||
helpers = set()
|
||||
for _, command in _grants(FIRST_TIME) | _grants(CONFIGURE):
|
||||
m = re.search(r"scripts/fix_perms/([\w.-]+\.sh)", command)
|
||||
if m:
|
||||
helpers.add(m.group(1))
|
||||
return helpers
|
||||
|
||||
|
||||
def test_every_granted_helper_is_hardened_in_configure_web_sudo():
|
||||
text = _text(CONFIGURE)
|
||||
variables = _variables(text)
|
||||
@@ -138,9 +226,8 @@ def test_no_grant_runs_a_file_the_web_user_can_edit():
|
||||
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}")
|
||||
for _, command in _generator_grants():
|
||||
for token in command.split():
|
||||
if token.startswith("$PROJECT_ROOT/"):
|
||||
assert token.startswith("$PROJECT_ROOT/scripts/fix_perms/"), (
|
||||
f"lib_sudoers.sh grants root on a user-owned file: {command}")
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Names two rarely-run api_v3 paths call must exist.
|
||||
|
||||
Both slipped through because nothing exercised them: the Pixlet editor's
|
||||
stop route only restarts the display after a SIGKILL, and the Starlark
|
||||
device-location resolver only builds a cache manager when the web app has
|
||||
not set one. Either raised NameError when it finally ran.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_module # noqa: F401
|
||||
|
||||
|
||||
def test_the_editor_stop_route_can_restart_the_display():
|
||||
from web_interface.blueprints.api_v3 import starlark
|
||||
|
||||
assert callable(starlark._run_systemctl_command)
|
||||
|
||||
|
||||
def test_the_device_location_resolver_builds_without_a_cache_manager(api_v3_module):
|
||||
pkg = api_v3_module
|
||||
with patch.object(pkg.api_v3, 'cache_manager', None, create=True), \
|
||||
patch.object(pkg, '_starlark_device_location', None):
|
||||
resolver = pkg._get_starlark_device_location()
|
||||
assert resolver.cache_manager is None
|
||||
@@ -0,0 +1,221 @@
|
||||
"""An exception no api_v3 route catches is answered once, by the blueprint.
|
||||
|
||||
Fifty-three routes used to end in a copy of the same catch-all:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(..., exc_info=True)
|
||||
return jsonify({'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(e)}), 500
|
||||
|
||||
They were removed in favour of one errorhandler on the api_v3 blueprint. These
|
||||
tests pin that the answer did not change: the same status, exactly the same
|
||||
keys and values, credentials still redacted, and the traceback still logged.
|
||||
|
||||
The exact-equality matters. web_interface/app.py's global handler answers with
|
||||
an extra `error_code: UNKNOWN_ERROR`, and the plugin API client routes a body
|
||||
that carries an error_code to a different UI path (api_client.js) -- so
|
||||
"falls through to the global handler" would not have been the same answer.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import UnsupportedMediaType
|
||||
|
||||
from src.web_interface.error_handler import describe_exception
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
|
||||
# A credential in three of the forms describe_exception redacts.
|
||||
FORCED = RuntimeError(
|
||||
"forced failure token=SECRET123 at https://u:pw1@example.com/x?api_key=K1")
|
||||
|
||||
# What every removed catch-all returned, written out rather than imported so
|
||||
# a change to the shared payload cannot also change the expectation.
|
||||
EXPECTED = {
|
||||
'status': 'error',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(FORCED),
|
||||
}
|
||||
|
||||
MANAGERS = ("config_manager", "plugin_manager", "plugin_store_manager",
|
||||
"saved_repositories_manager", "schema_manager", "operation_queue",
|
||||
"plugin_state_manager", "operation_history", "cache_manager")
|
||||
|
||||
|
||||
class Boom:
|
||||
"""A manager that fails on any use -- attribute, truthiness, call."""
|
||||
|
||||
def _raise(self, *args, **kwargs):
|
||||
raise FORCED
|
||||
|
||||
__getattr__ = _raise
|
||||
__bool__ = _raise
|
||||
__call__ = _raise
|
||||
__iter__ = _raise
|
||||
__len__ = _raise
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def exploding_managers(monkeypatch):
|
||||
for name in MANAGERS:
|
||||
monkeypatch.setattr(api_v3, name, Boom(), raising=False)
|
||||
# The WiFi routes build their own manager rather than using one above.
|
||||
import src.wifi_manager
|
||||
monkeypatch.setattr(src.wifi_manager, "WiFiManager", Boom())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(exploding_managers):
|
||||
"""The blueprint alone, on an app with no error handlers of its own."""
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
return app.test_client()
|
||||
|
||||
|
||||
# A sample of routes whose catch-all was removed, across every module that
|
||||
# lost one. Each reaches a manager (or WiFiManager) inside what used to be
|
||||
# the try block.
|
||||
REMOVED_CATCH_ALLS = [
|
||||
("GET", "/api/v3/config/main", None),
|
||||
("GET", "/api/v3/config/secrets", None),
|
||||
("GET", "/api/v3/display/modes", None),
|
||||
("POST", "/api/v3/display/on-demand/stop", {}),
|
||||
("GET", "/api/v3/cache/list", None),
|
||||
("GET", "/api/v3/plugins/installed", None),
|
||||
("GET", "/api/v3/plugins/health", None),
|
||||
("GET", "/api/v3/plugins/metrics/some-plugin", None),
|
||||
("GET", "/api/v3/plugins/schema?plugin_id=some-plugin", None),
|
||||
("GET", "/api/v3/plugins/store/list", None),
|
||||
("GET", "/api/v3/plugins/saved-repositories", None),
|
||||
("POST", "/api/v3/plugins/install", {"plugin_id": "some-plugin"}),
|
||||
("POST", "/api/v3/plugins/config/reset?plugin_id=some-plugin", {}),
|
||||
("GET", "/api/v3/plugins/limits/some-plugin", None),
|
||||
("GET", "/api/v3/wifi/status", None),
|
||||
("POST", "/api/v3/wifi/disconnect", {}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method,url,body", REMOVED_CATCH_ALLS,
|
||||
ids=[f"{m} {u}" for m, u, _ in REMOVED_CATCH_ALLS])
|
||||
def test_the_answer_is_what_the_catch_all_returned(client, caplog, method, url, body):
|
||||
with caplog.at_level(logging.ERROR, logger="web_interface.blueprints.api_v3"):
|
||||
resp = client.open(url, method=method, json=body)
|
||||
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json() == EXPECTED
|
||||
|
||||
# The promise in the message: the traceback is in the log.
|
||||
records = [r for r in caplog.records
|
||||
if r.name == "web_interface.blueprints.api_v3"
|
||||
and r.levelno >= logging.ERROR and r.exc_info]
|
||||
assert records, "the unhandled exception was not logged with its traceback"
|
||||
assert records[-1].exc_info[1] is FORCED
|
||||
|
||||
|
||||
def test_credentials_are_redacted_from_the_detail(client):
|
||||
body = client.get("/api/v3/plugins/installed").get_json()
|
||||
for secret in ("SECRET123", "pw1", "K1"):
|
||||
assert secret not in body["details"]
|
||||
assert "<redacted>" in body["details"]
|
||||
assert body["details"].startswith("RuntimeError: forced failure")
|
||||
|
||||
|
||||
def test_a_client_error_keeps_its_own_status(client):
|
||||
"""HTTPExceptions subclass Exception; a 415 must not become a 500."""
|
||||
resp = client.post("/api/v3/plugins/assets/delete", data="not json",
|
||||
content_type="text/plain")
|
||||
assert resp.status_code == 415
|
||||
body = resp.get_json()
|
||||
assert body == {
|
||||
'status': 'error',
|
||||
'error_code': 'UNSUPPORTED_MEDIA_TYPE',
|
||||
'message': body['message'],
|
||||
}
|
||||
assert "Content-Type" in body['message']
|
||||
|
||||
|
||||
class TestInTheRealApp:
|
||||
"""Mounted in web_interface/app.py, beside its global handlers."""
|
||||
|
||||
@pytest.fixture
|
||||
def web_app(self):
|
||||
import web_interface.app as web_app
|
||||
return web_app
|
||||
|
||||
def test_the_blueprint_handler_answers_not_the_global_one(
|
||||
self, web_app, exploding_managers):
|
||||
resp = web_app.app.test_client().get("/api/v3/plugins/installed")
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json() == EXPECTED
|
||||
|
||||
def test_client_errors_read_the_same_as_the_global_handler(
|
||||
self, web_app, exploding_managers):
|
||||
"""The blueprint's 4xx shape must not drift from app.py's."""
|
||||
resp = web_app.app.test_client().post(
|
||||
"/api/v3/plugins/assets/delete", data="not json",
|
||||
content_type="text/plain")
|
||||
with web_app.app.test_request_context():
|
||||
global_resp, global_status = web_app.handle_exception(
|
||||
UnsupportedMediaType(description=resp.get_json()['message']))
|
||||
assert resp.status_code == global_status == 415
|
||||
assert resp.get_json() == global_resp.get_json()
|
||||
|
||||
def test_global_handler_shape(self, web_app):
|
||||
"""Everything outside api_v3 still gets app.py's answer."""
|
||||
with web_app.app.test_request_context("/somewhere"):
|
||||
resp, status = web_app.handle_exception(FORCED)
|
||||
body = resp.get_json()
|
||||
assert status == 500
|
||||
assert body == {
|
||||
'status': 'error',
|
||||
'error_code': 'UNKNOWN_ERROR',
|
||||
'message': 'An error occurred; see logs for details',
|
||||
'details': describe_exception(FORCED),
|
||||
}
|
||||
assert "SECRET123" not in body["details"]
|
||||
|
||||
|
||||
class TestPluginActionStep1:
|
||||
"""execute_plugin_action's OAuth step-1 handler reports the script's error.
|
||||
|
||||
The route bound a local `logger` in its JSON-parsing arm, which made
|
||||
`logger` local to the whole function; every other `logger.error` in it
|
||||
then raised UnboundLocalError. The step-1 handler therefore answered
|
||||
"UnboundLocalError: cannot access local variable 'logger'" instead of
|
||||
whatever the plugin's auth script actually raised.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_dir(self, tmp_path):
|
||||
import json
|
||||
d = tmp_path / "demo-plugin"
|
||||
d.mkdir()
|
||||
(d / "manifest.json").write_text(json.dumps({
|
||||
"id": "demo-plugin",
|
||||
"web_ui_actions": [{"id": "auth", "type": "script",
|
||||
"script": "auth.py", "oauth_flow": True}],
|
||||
}), encoding="utf-8")
|
||||
(d / "auth.py").write_text(
|
||||
"def get_auth_url():\n"
|
||||
" raise RuntimeError('the auth script failed')\n",
|
||||
encoding="utf-8")
|
||||
return d
|
||||
|
||||
def test_the_script_error_reaches_the_response(self, plugin_dir, monkeypatch):
|
||||
from unittest.mock import MagicMock
|
||||
manager = MagicMock()
|
||||
manager.get_plugin_directory.return_value = str(plugin_dir)
|
||||
monkeypatch.setattr(api_v3, "plugin_manager", manager, raising=False)
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
|
||||
resp = app.test_client().post(
|
||||
"/api/v3/plugins/action",
|
||||
json={"plugin_id": "demo-plugin", "action_id": "auth"})
|
||||
|
||||
assert resp.status_code == 500
|
||||
body = resp.get_json()
|
||||
assert body["details"] == "RuntimeError: the auth script failed"
|
||||
assert body["message"] == 'An error occurred; see logs for details'
|
||||
@@ -1,9 +1,14 @@
|
||||
"""Tests for the web interface's in-memory cache helpers."""
|
||||
import sys
|
||||
import threading
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from web_interface.cache import delete_cached, get_cached, invalidate_cache, set_cached
|
||||
from web_interface import cache as cache_module
|
||||
from web_interface.cache import (
|
||||
TTLCache, delete_cached, get_cached, invalidate_cache, set_cached,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -44,3 +49,142 @@ def test_invalidate_cache_pattern() -> None:
|
||||
invalidate_cache('fonts')
|
||||
assert get_cached('fonts_catalog') is None
|
||||
assert get_cached('plugins_list') == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Expiry. set_cached used to accept ttl_seconds and ignore it; only the TTL a
|
||||
# reader passed to get_cached counted, and get_cached defaulted to 60s.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _Clock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 1000.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock() -> _Clock:
|
||||
return _Clock()
|
||||
|
||||
|
||||
def test_entry_expires_after_its_ttl(clock: _Clock) -> None:
|
||||
c = TTLCache(clock=clock)
|
||||
c.set('k', 'v', ttl=10)
|
||||
clock.now += 9.9
|
||||
assert c.get('k') == 'v'
|
||||
clock.now += 0.1
|
||||
assert c.get('k') is None
|
||||
|
||||
|
||||
def test_default_ttl_applies_when_none_given(clock: _Clock) -> None:
|
||||
c = TTLCache(default_ttl=5, clock=clock)
|
||||
c.set('k', 'v')
|
||||
clock.now += 4.9
|
||||
assert c.get('k') == 'v'
|
||||
clock.now += 0.1
|
||||
assert c.get('k') is None
|
||||
|
||||
|
||||
def test_reader_max_age_can_only_shorten(clock: _Clock) -> None:
|
||||
c = TTLCache(clock=clock)
|
||||
c.set('k', 'v', ttl=10)
|
||||
clock.now += 5
|
||||
assert c.get('k', max_age=6) == 'v'
|
||||
assert c.get('k', max_age=5) is None
|
||||
clock.now += 5
|
||||
assert c.get('k', max_age=60) is None, "a reader extended a 10s entry"
|
||||
|
||||
|
||||
def test_set_cached_ttl_is_honoured(monkeypatch: pytest.MonkeyPatch, clock: _Clock) -> None:
|
||||
monkeypatch.setattr(cache_module, '_default_cache', TTLCache(clock=clock))
|
||||
set_cached('short', 1, ttl_seconds=2)
|
||||
set_cached('long', 2, ttl_seconds=300)
|
||||
clock.now += 2
|
||||
assert get_cached('short') is None, "set_cached ignored its ttl_seconds"
|
||||
clock.now += 100 # past the old implicit 60s read default
|
||||
assert get_cached('long') == 2
|
||||
|
||||
|
||||
def test_get_cached_ttl_still_bounds_the_read(monkeypatch: pytest.MonkeyPatch, clock: _Clock) -> None:
|
||||
"""The existing callers pass the TTL on both sides; that keeps working."""
|
||||
monkeypatch.setattr(cache_module, '_default_cache', TTLCache(clock=clock))
|
||||
set_cached('system_status', {'cpu': 1}, ttl_seconds=10)
|
||||
clock.now += 9
|
||||
assert get_cached('system_status', ttl_seconds=10) == {'cpu': 1}
|
||||
clock.now += 1
|
||||
assert get_cached('system_status', ttl_seconds=10) is None
|
||||
|
||||
|
||||
def test_peek_returns_the_last_value_after_expiry(clock: _Clock) -> None:
|
||||
c = TTLCache(clock=clock)
|
||||
assert c.peek('k', 'fallback') == 'fallback'
|
||||
c.set('k', True, ttl=1)
|
||||
clock.now += 5
|
||||
assert c.get('k') is None
|
||||
assert c.peek('k', False) is True
|
||||
|
||||
|
||||
def test_falsy_values_are_cached(clock: _Clock) -> None:
|
||||
c = TTLCache(clock=clock)
|
||||
c.set('k', False, ttl=10)
|
||||
assert c.get('k', default='miss') is False
|
||||
|
||||
|
||||
def test_clear_pattern_on_instance() -> None:
|
||||
c = TTLCache()
|
||||
c.set('fonts_catalog', 1)
|
||||
c.set('system_status', 2)
|
||||
c.clear('fonts')
|
||||
assert c.peek('fonts_catalog') is None
|
||||
assert c.get('system_status') == 2
|
||||
c.clear()
|
||||
assert c.peek('system_status') is None
|
||||
|
||||
|
||||
def test_concurrent_expiry_reads_and_writes_do_not_raise() -> None:
|
||||
"""The old dicts deleted expired keys inside get; two threads reading the
|
||||
same expired key (or one reading while another invalidated) could raise
|
||||
KeyError, which the endpoints turned into a 500."""
|
||||
c = TTLCache()
|
||||
keys = [f'k{n}' for n in range(8)]
|
||||
errors = []
|
||||
stop = threading.Event()
|
||||
|
||||
def reader() -> None:
|
||||
try:
|
||||
while not stop.is_set():
|
||||
for key in keys:
|
||||
c.get(key, max_age=0) # always expired for this reader
|
||||
c.get(key)
|
||||
c.peek(key)
|
||||
c.clear('k1')
|
||||
except Exception as exc: # pragma: no cover - the failure being tested
|
||||
errors.append(exc)
|
||||
|
||||
def writer() -> None:
|
||||
try:
|
||||
for i in range(20000):
|
||||
key = keys[i % len(keys)]
|
||||
c.set(key, i, ttl=0 if i % 2 else 60)
|
||||
if i % 7 == 0:
|
||||
c.delete(key)
|
||||
except Exception as exc: # pragma: no cover
|
||||
errors.append(exc)
|
||||
|
||||
# Switch threads as often as possible so an unlocked check-then-act
|
||||
# actually gets interleaved within the test's run time.
|
||||
old_interval = sys.getswitchinterval()
|
||||
sys.setswitchinterval(1e-6)
|
||||
try:
|
||||
readers = [threading.Thread(target=reader) for _ in range(4)]
|
||||
for t in readers:
|
||||
t.start()
|
||||
writer()
|
||||
stop.set()
|
||||
for t in readers:
|
||||
t.join()
|
||||
finally:
|
||||
sys.setswitchinterval(old_interval)
|
||||
assert errors == []
|
||||
|
||||
@@ -15,7 +15,7 @@ every api_v3 endpoint actually calls.
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from src.web_interface.api_helpers import success_response
|
||||
from src.web_interface.api_helpers import exception_error_response, success_response
|
||||
from src.web_interface.error_handler import (
|
||||
create_error_response,
|
||||
create_success_response,
|
||||
@@ -64,6 +64,59 @@ class TestCreateErrorResponse:
|
||||
assert response.get_json()["suggested_fixes"] == ["Try again"]
|
||||
|
||||
|
||||
class TestExceptionErrorResponse:
|
||||
"""The one-call form of from_exception() + error_response().
|
||||
|
||||
Nine plugin routes spelled the pair out by hand; these pin that the helper
|
||||
answers exactly what that spelling did, so folding them changed nothing a
|
||||
client sees.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _by_hand(exc, code, with_context):
|
||||
from src.web_interface.api_helpers import error_response
|
||||
error = WebInterfaceError.from_exception(exc, code)
|
||||
if with_context:
|
||||
return error_response(error.error_code, error.message,
|
||||
details=error.details, context=error.context,
|
||||
status_code=500)
|
||||
return error_response(error.error_code, error.message,
|
||||
details=error.details, status_code=500)
|
||||
|
||||
@pytest.mark.parametrize("with_context", [True, False])
|
||||
@pytest.mark.parametrize("code", [ErrorCode.SYSTEM_ERROR,
|
||||
ErrorCode.CONFIG_SAVE_FAILED,
|
||||
ErrorCode.PLUGIN_UPDATE_FAILED])
|
||||
def test_same_answer_as_the_hand_written_pair(self, app, code, with_context):
|
||||
exc = ValueError("token=SECRET boom")
|
||||
exc.context = {"config_path": "/etc/x.json"}
|
||||
with app.test_request_context():
|
||||
got, got_status = exception_error_response(
|
||||
exc, code, with_context=with_context)
|
||||
want, want_status = self._by_hand(exc, code, with_context)
|
||||
assert got_status == want_status == 500
|
||||
assert got.get_json() == want.get_json()
|
||||
|
||||
def test_shape(self, app):
|
||||
with app.test_request_context():
|
||||
response, status = exception_error_response(
|
||||
RuntimeError("token=SECRET"), ErrorCode.SYSTEM_ERROR)
|
||||
assert status == 500
|
||||
assert response.get_json() == {
|
||||
"status": "error",
|
||||
"error_code": "SYSTEM_ERROR",
|
||||
"message": "A system error occurred",
|
||||
"context": {"exception_type": "RuntimeError"},
|
||||
"suggested_fixes": ["Review error details and try again"],
|
||||
}
|
||||
|
||||
def test_without_context(self, app):
|
||||
with app.test_request_context():
|
||||
response, _ = exception_error_response(
|
||||
RuntimeError("x"), ErrorCode.SYSTEM_ERROR, with_context=False)
|
||||
assert "context" not in response.get_json()
|
||||
|
||||
|
||||
class TestCreateSuccessResponse:
|
||||
def test_bare_success(self):
|
||||
assert create_success_response() == {"status": "success"}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Tests for src/web_interface/errors.py — the structured error type behind
|
||||
every API error response (category inference, default suggestions, the
|
||||
JSON shape, and exception conversion).
|
||||
every API error response (default suggestions, the JSON shape, and
|
||||
exception conversion).
|
||||
|
||||
Pure logic; no Flask context needed.
|
||||
|
||||
@@ -11,39 +11,7 @@ caller passing [] to mean "no suggestions" silently got the default list.
|
||||
|
||||
import pytest
|
||||
|
||||
from src.web_interface.errors import ErrorCategory, ErrorCode, WebInterfaceError
|
||||
|
||||
|
||||
class TestCategoryInference:
|
||||
@pytest.mark.parametrize("code,expected", [
|
||||
(ErrorCode.CONFIG_SAVE_FAILED, ErrorCategory.CONFIGURATION),
|
||||
(ErrorCode.CONFIG_ROLLBACK_FAILED, ErrorCategory.CONFIGURATION),
|
||||
(ErrorCode.PLUGIN_NOT_FOUND, ErrorCategory.PLUGIN),
|
||||
(ErrorCode.PLUGIN_OPERATION_CONFLICT, ErrorCategory.PLUGIN),
|
||||
(ErrorCode.VALIDATION_ERROR, ErrorCategory.VALIDATION),
|
||||
(ErrorCode.SCHEMA_VALIDATION_FAILED, ErrorCategory.VALIDATION),
|
||||
(ErrorCode.INVALID_INPUT, ErrorCategory.VALIDATION),
|
||||
(ErrorCode.NETWORK_ERROR, ErrorCategory.NETWORK),
|
||||
(ErrorCode.API_ERROR, ErrorCategory.NETWORK),
|
||||
(ErrorCode.TIMEOUT, ErrorCategory.NETWORK),
|
||||
(ErrorCode.PERMISSION_DENIED, ErrorCategory.PERMISSION),
|
||||
(ErrorCode.FILE_PERMISSION_ERROR, ErrorCategory.PERMISSION),
|
||||
(ErrorCode.SYSTEM_ERROR, ErrorCategory.SYSTEM),
|
||||
(ErrorCode.SERVICE_UNAVAILABLE, ErrorCategory.SYSTEM),
|
||||
(ErrorCode.UNKNOWN_ERROR, ErrorCategory.UNKNOWN),
|
||||
])
|
||||
def test_every_code_prefix_maps_to_its_category(self, code, expected):
|
||||
assert WebInterfaceError(code, "msg").category is expected
|
||||
|
||||
def test_explicit_category_overrides_inference(self):
|
||||
error = WebInterfaceError(
|
||||
ErrorCode.CONFIG_SAVE_FAILED, "msg", category=ErrorCategory.SYSTEM)
|
||||
assert error.category is ErrorCategory.SYSTEM
|
||||
|
||||
def test_every_error_code_gets_a_category(self):
|
||||
# No code may fall through uncategorized as the enum grows.
|
||||
for code in ErrorCode:
|
||||
assert isinstance(WebInterfaceError(code, "msg").category, ErrorCategory)
|
||||
from src.web_interface.errors import ErrorCode, WebInterfaceError
|
||||
|
||||
|
||||
class TestDefaultSuggestions:
|
||||
@@ -79,8 +47,9 @@ class TestToDict:
|
||||
result = WebInterfaceError(ErrorCode.SYSTEM_ERROR, "boom").to_dict()
|
||||
assert result["status"] == "error"
|
||||
assert result["error_code"] == "SYSTEM_ERROR"
|
||||
assert result["error_category"] == "system"
|
||||
assert result["message"] == "boom"
|
||||
# No error_category: nothing in the UI, tests or plugins ever read it.
|
||||
assert set(result) == {"status", "error_code", "message", "suggested_fixes"}
|
||||
|
||||
def test_details_included_when_set(self):
|
||||
result = WebInterfaceError(
|
||||
@@ -116,23 +85,7 @@ class TestToDict:
|
||||
|
||||
|
||||
class TestFromException:
|
||||
@pytest.mark.parametrize("exc_name,expected", [
|
||||
("ConfigError", ErrorCode.CONFIG_LOAD_FAILED),
|
||||
("PluginError", ErrorCode.PLUGIN_LOAD_FAILED),
|
||||
("PermissionError", ErrorCode.PERMISSION_DENIED),
|
||||
("AccessDenied", ErrorCode.PERMISSION_DENIED),
|
||||
("ValidationError", ErrorCode.VALIDATION_ERROR),
|
||||
("SchemaError", ErrorCode.VALIDATION_ERROR),
|
||||
("NetworkError", ErrorCode.NETWORK_ERROR),
|
||||
("ConnectionError", ErrorCode.NETWORK_ERROR),
|
||||
("TimeoutError", ErrorCode.TIMEOUT),
|
||||
("SomethingElse", ErrorCode.UNKNOWN_ERROR),
|
||||
])
|
||||
def test_code_inferred_from_exception_class_name(self, exc_name, expected):
|
||||
exc = type(exc_name, (Exception,), {})("boom")
|
||||
assert WebInterfaceError.from_exception(exc).error_code is expected
|
||||
|
||||
def test_explicit_code_skips_inference(self):
|
||||
def test_the_given_code_is_reported(self):
|
||||
error = WebInterfaceError.from_exception(
|
||||
ValueError("boom"), error_code=ErrorCode.PLUGIN_NOT_FOUND)
|
||||
assert error.error_code is ErrorCode.PLUGIN_NOT_FOUND
|
||||
@@ -140,28 +93,28 @@ class TestFromException:
|
||||
def test_message_is_the_safe_one_not_the_exception_text(self):
|
||||
# The raw exception text is not echoed into `message`; that field is
|
||||
# a fixed, user-facing string per code.
|
||||
error = WebInterfaceError.from_exception(ValueError("secret-ish detail"))
|
||||
error = WebInterfaceError.from_exception(ValueError("secret-ish detail"), ErrorCode.UNKNOWN_ERROR)
|
||||
assert error.message == "An unexpected error occurred"
|
||||
assert "secret-ish" not in error.message
|
||||
|
||||
def test_exception_type_recorded_in_context(self):
|
||||
error = WebInterfaceError.from_exception(ValueError("boom"))
|
||||
error = WebInterfaceError.from_exception(ValueError("boom"), ErrorCode.UNKNOWN_ERROR)
|
||||
assert error.context["exception_type"] == "ValueError"
|
||||
|
||||
def test_caller_context_is_preserved_alongside_type(self):
|
||||
error = WebInterfaceError.from_exception(
|
||||
ValueError("boom"), context={"plugin_id": "clock"})
|
||||
ValueError("boom"), ErrorCode.UNKNOWN_ERROR, context={"plugin_id": "clock"})
|
||||
assert error.context["plugin_id"] == "clock"
|
||||
assert error.context["exception_type"] == "ValueError"
|
||||
|
||||
def test_caller_supplied_exception_type_is_overwritten(self):
|
||||
error = WebInterfaceError.from_exception(
|
||||
ValueError("boom"), context={"exception_type": "Fake"})
|
||||
ValueError("boom"), ErrorCode.UNKNOWN_ERROR, context={"exception_type": "Fake"})
|
||||
assert error.context["exception_type"] == "ValueError"
|
||||
|
||||
def test_original_error_retained(self):
|
||||
exc = ValueError("boom")
|
||||
assert WebInterfaceError.from_exception(exc).original_error is exc
|
||||
assert WebInterfaceError.from_exception(exc, ErrorCode.UNKNOWN_ERROR).original_error is exc
|
||||
|
||||
def test_every_code_has_a_safe_message(self):
|
||||
for code in ErrorCode:
|
||||
@@ -205,4 +158,4 @@ class TestExceptionDetails:
|
||||
def test_details_flow_into_from_exception(self):
|
||||
exc = ValueError("boom")
|
||||
exc.context = {"config_path": "/etc/x.json"}
|
||||
assert "config_path" in WebInterfaceError.from_exception(exc).details
|
||||
assert "config_path" in WebInterfaceError.from_exception(exc, ErrorCode.UNKNOWN_ERROR).details
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Guards that every privileged systemctl call the web interface makes is
|
||||
covered by a passwordless-sudo grant in configure_web_sudo.sh.
|
||||
covered by a passwordless-sudo grant in scripts/install/lib_sudoers.sh, which
|
||||
both first_time_install.sh and configure_web_sudo.sh write the rules from.
|
||||
|
||||
The web interface runs headless (no TTY), so any `sudo` call that is not
|
||||
matched by a NOPASSWD rule in /etc/sudoers.d/ledmatrix_web falls back to a
|
||||
@@ -25,7 +26,7 @@ API_V3_PKG = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3"
|
||||
|
||||
def _api_v3_source() -> str:
|
||||
return "\n".join(p.read_text() for p in sorted(API_V3_PKG.glob("*.py")))
|
||||
SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "configure_web_sudo.sh"
|
||||
SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "lib_sudoers.sh"
|
||||
|
||||
|
||||
def _sudo_systemctl_calls(source: str) -> set[tuple[str, str]]:
|
||||
@@ -64,7 +65,7 @@ def test_every_sudo_systemctl_call_is_granted() -> None:
|
||||
uncovered = {c for c in calls if c not in rules}
|
||||
assert not uncovered, (
|
||||
"These sudo systemctl calls have no matching NOPASSWD grant in "
|
||||
"configure_web_sudo.sh; they will fail headless with "
|
||||
"lib_sudoers.sh; they will fail headless with "
|
||||
"'sudo: a terminal is required to read the password': "
|
||||
+ ", ".join(f"systemctl {v} {u}" for v, u in sorted(uncovered))
|
||||
)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""The web process logs the way the display process does.
|
||||
|
||||
web_interface/app.py used to call its own setup (web_interface/logging_config.py)
|
||||
which replaced the root handlers with a plain stdout formatter. Under systemd
|
||||
every line then reached the journal as PRIORITY=6, so
|
||||
|
||||
journalctl -p err -u ledmatrix-web
|
||||
|
||||
showed nothing while the web interface was logging errors. It also logged
|
||||
every request at INFO, including what the UI polls: the journal on a Pi showed
|
||||
``GET /api/v3/errors/summary - 200`` every minute per open tab.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from web_interface import request_logging
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The real app, imported the way systemd runs it
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CHILD = textwrap.dedent("""
|
||||
import logging
|
||||
import web_interface.app as web_app
|
||||
|
||||
# Startup reconciliation may try to reinstall plugins; not this test's job.
|
||||
web_app._reconciliation_started = True
|
||||
client = web_app.app.test_client()
|
||||
client.get('/api/v3/errors/summary')
|
||||
client.get('/favicon.ico')
|
||||
client.get('/api/v3/no-such-endpoint')
|
||||
logging.getLogger('web_interface.probe').error('probe error line')
|
||||
logging.getLogger('web_interface.probe').info('probe info line')
|
||||
""")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def journal_output(tmp_path_factory):
|
||||
"""Run the child with stdout as a file systemd would call the journal.
|
||||
|
||||
systemd sets JOURNAL_STREAM to the dev:ino of the stream it captures;
|
||||
src.logging_config only adds priorities when stdout really is that stream,
|
||||
so hand the child a file and name that file's dev:ino.
|
||||
"""
|
||||
out_path = tmp_path_factory.mktemp("journal") / "stdout.txt"
|
||||
with open(out_path, "wb") as out:
|
||||
st = os.fstat(out.fileno())
|
||||
env = dict(os.environ)
|
||||
env.update({
|
||||
"JOURNAL_STREAM": f"{st.st_dev}:{st.st_ino}",
|
||||
"PYTHONUTF8": "1",
|
||||
"EMULATOR": "true",
|
||||
"PYTHONPATH": str(PROJECT_ROOT),
|
||||
})
|
||||
env.pop("LEDMATRIX_DEBUG", None)
|
||||
env.pop("LEDMATRIX_JSON_LOGGING", None)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", _CHILD], cwd=str(PROJECT_ROOT), env=env,
|
||||
stdout=out, stderr=subprocess.PIPE, timeout=180,
|
||||
)
|
||||
text = out_path.read_text(encoding="utf-8", errors="replace")
|
||||
assert proc.returncode == 0, proc.stderr.decode(errors="replace")[-4000:]
|
||||
return text.splitlines()
|
||||
|
||||
|
||||
def test_error_reaches_the_journal_as_err(journal_output):
|
||||
lines = [l for l in journal_output if "probe error line" in l]
|
||||
assert lines, "\n".join(journal_output[-40:])
|
||||
assert lines[0].startswith("<3>"), lines[0]
|
||||
# Same readable shape as the display service (and what the log viewer strips).
|
||||
assert " - ERROR - web_interface.probe - probe error line" in lines[0]
|
||||
|
||||
|
||||
def test_info_reaches_the_journal_as_info(journal_output):
|
||||
lines = [l for l in journal_output if "probe info line" in l]
|
||||
assert lines and lines[0].startswith("<6>"), journal_output[-40:]
|
||||
|
||||
|
||||
def test_polling_gets_are_not_logged_at_info(journal_output):
|
||||
for path in ("/api/v3/errors/summary", "/favicon.ico"):
|
||||
assert not [l for l in journal_output if f"GET {path} " in l], (
|
||||
f"a successful GET {path} was logged by default")
|
||||
|
||||
|
||||
def test_failed_request_is_still_logged(journal_output):
|
||||
lines = [l for l in journal_output if "GET /api/v3/no-such-endpoint - 404" in l]
|
||||
assert lines and lines[0].startswith("<4>"), journal_output[-40:]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The level policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("method,status,level", [
|
||||
("GET", 200, logging.DEBUG),
|
||||
("GET", 304, logging.DEBUG),
|
||||
("HEAD", 200, logging.DEBUG),
|
||||
("OPTIONS", 204, logging.DEBUG),
|
||||
("get", 200, logging.DEBUG),
|
||||
("POST", 200, logging.INFO),
|
||||
("PUT", 204, logging.INFO),
|
||||
("DELETE", 200, logging.INFO),
|
||||
("PATCH", 302, logging.INFO),
|
||||
("GET", 404, logging.WARNING),
|
||||
("POST", 400, logging.WARNING),
|
||||
("GET", 500, logging.ERROR),
|
||||
("POST", 503, logging.ERROR),
|
||||
])
|
||||
def test_request_log_level(method, status, level):
|
||||
assert request_logging.request_log_level(method, status) == level
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tiny_app():
|
||||
app = Flask(__name__)
|
||||
request_logging.init_app(app)
|
||||
|
||||
@app.route("/poll")
|
||||
def poll():
|
||||
return "ok"
|
||||
|
||||
@app.route("/save", methods=["POST"])
|
||||
def save():
|
||||
return "saved"
|
||||
|
||||
@app.route("/boom")
|
||||
def boom():
|
||||
return "no", 500
|
||||
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def test_hooks_log_each_request_once_at_its_level(tiny_app, caplog):
|
||||
caplog.set_level(logging.DEBUG, logger="web_interface.api")
|
||||
tiny_app.get("/poll")
|
||||
tiny_app.post("/save")
|
||||
tiny_app.get("/boom")
|
||||
tiny_app.get("/missing")
|
||||
got = [(r.levelno, r.getMessage().split(" (")[0]) for r in caplog.records
|
||||
if r.name == "web_interface.api"]
|
||||
assert got == [
|
||||
(logging.DEBUG, "GET /poll - 200"),
|
||||
(logging.INFO, "POST /save - 200"),
|
||||
(logging.ERROR, "GET /boom - 500"),
|
||||
(logging.WARNING, "GET /missing - 404"),
|
||||
]
|
||||
|
||||
|
||||
def test_duration_is_rounded(tiny_app, caplog):
|
||||
caplog.set_level(logging.DEBUG, logger="web_interface.api")
|
||||
tiny_app.post("/save")
|
||||
msg = caplog.records[-1].getMessage()
|
||||
duration = msg.rsplit("(", 1)[1]
|
||||
assert duration.endswith("ms)") and len(duration.split(".")[1]) == len("0ms)"), msg
|
||||
|
||||
|
||||
def test_success_response_timing_uses_the_same_clock():
|
||||
# request_logging stamps request.start_time from perf_counter; a reader
|
||||
# subtracting it from time.time() reported ~1.8e12 ms (found on a Pi).
|
||||
from src.web_interface.api_helpers import success_response
|
||||
app = Flask(__name__)
|
||||
request_logging.init_app(app)
|
||||
|
||||
@app.route('/timed')
|
||||
def timed():
|
||||
return success_response(data={}, metadata={})
|
||||
|
||||
body = app.test_client().get('/timed').get_json()
|
||||
assert 0 <= body['metadata']['response_time_ms'] < 10_000
|
||||
Reference in New Issue
Block a user