Add dashboard theme settings and contrast tests

This commit is contained in:
Luna 2026-06-16 02:29:45 -07:00
parent 7f4d5b42fd
commit bfef23fc4a
7 changed files with 424 additions and 53 deletions

View file

@ -1,6 +1,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests for the read-only web dashboard: data layer + auth (real server)."""
import json
import re
import ssl
import tempfile
import threading
@ -15,6 +16,12 @@ from enodia_sentinel.alert import Alert, Severity
from enodia_sentinel.config import Config
THEME_IDS = (
"console", "paper", "contrast", "pride", "trans", "dracula",
"solarized-dark", "solarized-light", "twilight",
)
def _make_cfg(tmp: Path) -> Config:
c = Config()
c.log_dir = tmp
@ -35,6 +42,55 @@ def _incident_alert(sig: str, pids=()):
tuple(pids), sid=100010, classtype="test")
def _theme_blocks(html: str):
blocks = {}
root = re.search(r":root\{(?P<body>.*?)\n \}", html, re.S)
if root:
blocks["console"] = _css_vars(root.group("body"))
for name, body in re.findall(r':root\[data-theme="([^"]+)"\]\{(.*?)\n \}', html, re.S):
blocks[name] = _css_vars(body)
return blocks
def _css_vars(block: str):
return {
name: value.strip()
for name, value in re.findall(r"--([\w-]+):([^;]+);", block)
}
def _hex_rgb(value: str):
value = value.strip()
if not value.startswith("#"):
return None
if len(value) == 4:
value = "#" + "".join(ch * 2 for ch in value[1:])
if not re.fullmatch(r"#[0-9A-Fa-f]{6}", value):
return None
return tuple(int(value[i:i + 2], 16) / 255.0 for i in (1, 3, 5))
def _linear_channel(value: float) -> float:
if value <= 0.04045:
return value / 12.92
return ((value + 0.055) / 1.055) ** 2.4
def _relative_luminance(rgb) -> float:
r, g, b = (_linear_channel(part) for part in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def _contrast(foreground: str, background: str) -> float:
fg = _hex_rgb(foreground)
bg = _hex_rgb(background)
if fg is None or bg is None:
raise AssertionError(f"Expected hex colors, got {foreground!r} / {background!r}")
high = max(_relative_luminance(fg), _relative_luminance(bg))
low = min(_relative_luminance(fg), _relative_luminance(bg))
return (high + 0.05) / (low + 0.05)
class TestDataLayer(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
@ -116,6 +172,68 @@ class TestDataLayer(unittest.TestCase):
self.assertEqual(rule["event"], "exec")
self.assertIn("conditions", rule)
def test_dashboard_has_persistent_theme_controls(self):
html = (web._STATIC / "dashboard.html").read_text()
self.assertIn('id="settingsMenu"', html)
self.assertIn('id="settingsBtn"', html)
self.assertIn('aria-label="Theme"', html)
for theme in THEME_IDS:
self.assertIn(f'data-theme="{theme}"', html)
self.assertIn("sentinel.theme", html)
self.assertIn("#55CDFC", html)
self.assertIn("#F7A8B8", html)
self.assertGreaterEqual(html.count("#55CDFC"), 8)
self.assertGreaterEqual(html.count("#F7A8B8"), 8)
trans_block = html.split(':root[data-theme="trans"]{', 1)[1].split("}", 1)[0]
self.assertIn("--ink:#000000", trans_block)
self.assertIn("--muted:#000000", trans_block)
self.assertIn("--soft:#000000", trans_block)
self.assertIn("--white:#000000", trans_block)
self.assertIn("--panel:#FFFFFF", trans_block)
self.assertIn("--panel2:#F7A8B8", trans_block)
self.assertIn("--rail:#55CDFC", trans_block)
self.assertNotIn("--amber:#FFFFFF", trans_block)
self.assertNotIn("--red:#F7A8B8", trans_block)
self.assertNotIn("--blue:#55CDFC", trans_block)
def test_dashboard_theme_registry_matches_css(self):
html = (web._STATIC / "dashboard.html").read_text()
blocks = _theme_blocks(html)
self.assertEqual(set(blocks), set(THEME_IDS))
options = re.search(r"const THEME_OPTIONS = \[(.*?)\];", html, re.S)
self.assertIsNotNone(options)
registered = tuple(re.findall(r'\["([^"]+)","[^"]+"\]', options.group(1)))
self.assertEqual(registered, THEME_IDS)
def test_dashboard_theme_text_contrast(self):
html = (web._STATIC / "dashboard.html").read_text()
blocks = _theme_blocks(html)
token_pairs = (
("ink", "bg"),
("ink", "panel"),
("muted", "panel"),
("soft", "panel2"),
("white", "panel"),
("active-ink", "accent"),
("red", "panel"),
("amber", "panel"),
("blue", "panel"),
("ok", "panel"),
)
for theme, tokens in blocks.items():
with self.subTest(theme=theme):
for token in (
"bg", "ink", "muted", "soft", "panel", "panel2", "accent",
"amber", "red", "blue", "ok", "white", "active-ink",
):
self.assertIn(token, tokens)
for foreground, background in token_pairs:
ratio = _contrast(tokens[foreground], tokens[background])
self.assertGreaterEqual(
ratio, 4.5,
f"{theme} {foreground} on {background} contrast is {ratio:.2f}",
)
class TestNetworkHelpers(unittest.TestCase):
def test_is_loopback(self):
@ -173,6 +291,13 @@ class TestAuth(unittest.TestCase):
req.add_header("Authorization", f"Bearer {token}")
return urllib.request.urlopen(req, timeout=4, context=self.ctx)
def _head(self, path, token=None):
url = f"https://127.0.0.1:{self.port}{path}"
req = urllib.request.Request(url, method="HEAD")
if token:
req.add_header("Authorization", f"Bearer {token}")
return urllib.request.urlopen(req, timeout=4, context=self.ctx)
def test_unauthorized_without_token(self):
with self.assertRaises(urllib.error.HTTPError) as cm:
self._get("/api/status")
@ -183,6 +308,12 @@ class TestAuth(unittest.TestCase):
data = json.loads(resp.read())
self.assertEqual(data["total_alerts"], 1)
def test_head_dashboard_with_token(self):
resp = self._head("/", token="secret-token")
self.assertEqual(resp.status, 200)
self.assertEqual(resp.headers.get_content_type(), "text/html")
self.assertEqual(resp.read(), b"")
def test_token_via_query_param(self):
resp = self._get("/api/alerts?token=secret-token")
self.assertEqual(resp.status, 200)