Add dashboard theme settings and contrast tests
This commit is contained in:
parent
7f4d5b42fd
commit
bfef23fc4a
7 changed files with 424 additions and 53 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue