feat(gui): add optional tkinter and Qt6 desktop dashboards
Add two optional windowed frontends over the same local state the TUI and web console read: `enodia-sentinel gui` (stdlib tkinter) and `enodia-sentinel gui-qt` (PySide6, new `[qt]` extra). Both share `gui/model.py`, a GUI-free presentation model over `tui.collect_model`, so every formatter is unit-testable without a display server. Only `app.py` and `qt_app.py` import GUI libraries, enforced by import-guard tests mirroring the tray applet's boundary. Tabs cover status, alerts, incidents, posture, integrity, response plans, and the event tail, plus daemon-control buttons via systemctl. Response plans are display-only; the GUIs never execute containment commands. Core runtime dependencies stay empty. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JX86xeoBJVBb16qHkDf53K
This commit is contained in:
parent
9e3575461b
commit
457c7604ba
15 changed files with 1270 additions and 0 deletions
40
tests/test_gui_imports.py
Normal file
40
tests/test_gui_imports.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Boundary tests for the desktop GUI package.
|
||||
|
||||
The logic module must not import tkinter so it can be tested on headless
|
||||
runners; only app.py may import GUI libraries.
|
||||
"""
|
||||
import re
|
||||
import tomllib
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GUI = ROOT / "enodia_sentinel" / "gui"
|
||||
|
||||
|
||||
class TestGuiBoundaries(unittest.TestCase):
|
||||
def test_model_has_no_tkinter_imports(self):
|
||||
text = (GUI / "model.py").read_text()
|
||||
self.assertFalse(
|
||||
re.search(r"\b(import tkinter|from tkinter)\b", text),
|
||||
"model.py must not import tkinter",
|
||||
)
|
||||
|
||||
def test_model_imports_without_tkinter(self):
|
||||
# Must succeed even when tkinter is unavailable.
|
||||
from enodia_sentinel.gui import model # noqa: F401
|
||||
|
||||
def test_app_module_imports_without_tkinter(self):
|
||||
# The shell is importable without a display/GUI libraries; runtime
|
||||
# use raises a graceful SystemExit when tkinter is absent.
|
||||
from enodia_sentinel.gui import app # noqa: F401
|
||||
|
||||
def test_pyproject_declares_gui_script(self):
|
||||
data = tomllib.loads((ROOT / "pyproject.toml").read_text())
|
||||
self.assertEqual(
|
||||
data["project"]["scripts"]["enodia-sentinel-gui"],
|
||||
"enodia_sentinel.gui.app:main",
|
||||
)
|
||||
# Core runtime deps stay empty; tkinter is stdlib.
|
||||
self.assertEqual(data["project"]["dependencies"], [])
|
||||
173
tests/test_gui_model.py
Normal file
173
tests/test_gui_model.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Tests for the GUI presentation model (GUI-free)."""
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from enodia_sentinel.config import Config
|
||||
from enodia_sentinel.gui.model import (
|
||||
alert_rows,
|
||||
collect,
|
||||
incident_rows,
|
||||
integrity_lines,
|
||||
plan_rows,
|
||||
plan_text,
|
||||
posture_rows,
|
||||
status_lines,
|
||||
)
|
||||
from enodia_sentinel.tray.state import parse_status
|
||||
|
||||
|
||||
class TestStatusLines(unittest.TestCase):
|
||||
def test_empty_status_shows_help(self):
|
||||
lines = status_lines({})
|
||||
self.assertTrue(any("No status available" in line for line in lines))
|
||||
self.assertTrue(any("Start the daemon" in line for line in lines))
|
||||
|
||||
def test_running_status_fields(self):
|
||||
lines = status_lines({
|
||||
"running": True,
|
||||
"host": "testhost",
|
||||
"version": "0.7.0",
|
||||
"total_alerts": 3,
|
||||
"counts": {"HIGH": 1, "MEDIUM": 2},
|
||||
"last_alert": "2026-07-10T12:00:00",
|
||||
"heartbeat_age": 12,
|
||||
"heartbeat_stale": False,
|
||||
"ebpf": "attached",
|
||||
"ebpf_exec": "on",
|
||||
"ebpf_syscall": "off",
|
||||
})
|
||||
text = "\n".join(lines)
|
||||
self.assertIn("testhost", text)
|
||||
self.assertIn("running", text)
|
||||
self.assertIn("3 total", text)
|
||||
self.assertIn("HIGH:1", text)
|
||||
self.assertIn("12s ago", text)
|
||||
|
||||
def test_stale_heartbeat_flag(self):
|
||||
lines = status_lines({"heartbeat_age": 90, "heartbeat_stale": True})
|
||||
self.assertTrue(any("STALE" in line for line in lines))
|
||||
|
||||
|
||||
class TestAlertRows(unittest.TestCase):
|
||||
def test_flattens_snapshots(self):
|
||||
rows = alert_rows([
|
||||
{"time": "2026-07-10T12:00:00", "snapshot": "alert-1.json",
|
||||
"alerts": [
|
||||
{"severity": "HIGH", "sid": 100001,
|
||||
"signature": "reverse_shell", "detail": "bash"},
|
||||
]},
|
||||
{"time": "2026-07-10T12:01:00", "snapshot": "alert-2.json",
|
||||
"alerts": [
|
||||
{"severity": "MEDIUM", "sid": 100002,
|
||||
"signature": "ld_preload", "detail": "libevil"},
|
||||
]},
|
||||
])
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(rows[0][1], "HIGH")
|
||||
self.assertEqual(rows[1][3], "ld_preload")
|
||||
|
||||
def test_empty(self):
|
||||
self.assertEqual(alert_rows([]), ())
|
||||
self.assertEqual(alert_rows(None), ())
|
||||
|
||||
|
||||
class TestIncidentRows(unittest.TestCase):
|
||||
def test_maps_fields(self):
|
||||
rows = incident_rows([
|
||||
{"id": "INC-1", "severity": "CRITICAL",
|
||||
"first_seen": "2026-07-10T12:00:00",
|
||||
"last_seen": "2026-07-10T12:05:00",
|
||||
"alert_count": 5,
|
||||
"signatures": ["reverse_shell", "egress"]},
|
||||
])
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0][0], "INC-1")
|
||||
self.assertEqual(rows[0][1], "CRITICAL")
|
||||
self.assertEqual(rows[0][4], 5)
|
||||
self.assertIn("reverse_shell", rows[0][5])
|
||||
|
||||
|
||||
class TestPostureRows(unittest.TestCase):
|
||||
def test_maps_findings(self):
|
||||
rows = posture_rows({
|
||||
"findings": [
|
||||
{"severity": "HIGH", "signature": "ssh_root_login",
|
||||
"detail": "PermitRootLogin yes"},
|
||||
],
|
||||
})
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0], ("HIGH", "ssh_root_login",
|
||||
"PermitRootLogin yes"))
|
||||
|
||||
|
||||
class TestIntegrityLines(unittest.TestCase):
|
||||
def test_empty(self):
|
||||
self.assertEqual(integrity_lines({}), ("No integrity state available.",))
|
||||
|
||||
def test_flattens_nested(self):
|
||||
lines = integrity_lines({
|
||||
"watchdog": {"ok": True, "last_check": "now"},
|
||||
"fim": ["/etc/passwd", "/etc/shadow"],
|
||||
})
|
||||
self.assertTrue(any("watchdog.ok: True" in line for line in lines))
|
||||
self.assertTrue(any("fim: 2 item(s)" in line for line in lines))
|
||||
|
||||
|
||||
class TestPlanRows(unittest.TestCase):
|
||||
def test_normal_plan(self):
|
||||
plans = [{
|
||||
"incident_id": "INC-9",
|
||||
"summary": "freeze and contain",
|
||||
"actions": [
|
||||
{"title": "Freeze evidence", "reason": "preserve",
|
||||
"commands": ["tar", "-czf", "evidence.tgz"]},
|
||||
{"title": "Stop process", "commands": ["kill", "-STOP", "1234"]},
|
||||
],
|
||||
}]
|
||||
rows, details = plan_rows(plans)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0], ("INC-9", 2, "freeze and contain"))
|
||||
self.assertIn("Freeze evidence", details["INC-9"])
|
||||
self.assertIn("$ kill", details["INC-9"])
|
||||
|
||||
def test_error_plan(self):
|
||||
plans = [{"incident_id": "INC-X", "error": "bundle missing"}]
|
||||
rows, details = plan_rows(plans)
|
||||
self.assertEqual(rows[0][2], "bundle missing")
|
||||
self.assertEqual(details["INC-X"], "bundle missing")
|
||||
|
||||
|
||||
class TestPlanText(unittest.TestCase):
|
||||
def test_empty_actions(self):
|
||||
text = plan_text({"incident_id": "INC-1", "actions": []})
|
||||
self.assertIn("(no actions suggested)", text)
|
||||
|
||||
|
||||
class TestCollect(unittest.TestCase):
|
||||
def test_collect_composes_model(self):
|
||||
cfg = Config.load(None)
|
||||
fake = {
|
||||
"status": {
|
||||
"running": True, "total_alerts": 1,
|
||||
"counts": {"HIGH": 1}, "host": "h", "version": "v",
|
||||
},
|
||||
"alerts": [{"time": "t", "alerts": [
|
||||
{"severity": "HIGH", "sid": 1, "signature": "s", "detail": "d"},
|
||||
]}],
|
||||
"incidents": [{"id": "INC-1", "severity": "HIGH",
|
||||
"first_seen": "t1", "last_seen": "t2",
|
||||
"alert_count": 1, "signatures": ["s"]}],
|
||||
"posture": {"findings": []},
|
||||
"integrity": {"ok": True},
|
||||
"events": ["line1"],
|
||||
"response": [{"incident_id": "INC-1", "actions": []}],
|
||||
}
|
||||
with patch("enodia_sentinel.gui.model.collect_model", return_value=fake):
|
||||
model = collect(cfg)
|
||||
self.assertTrue(model.header.running)
|
||||
self.assertEqual(len(model.alerts), 1)
|
||||
self.assertEqual(len(model.incidents), 1)
|
||||
self.assertIn("ok: True", model.integrity_lines)
|
||||
self.assertEqual(model.events, ("line1",))
|
||||
self.assertEqual(parse_status(fake["status"]).summary, model.header.summary)
|
||||
127
tests/test_gui_qt.py
Normal file
127
tests/test_gui_qt.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Functional tests for the Qt GUI frontend using the offscreen platform."""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from enodia_sentinel.config import Config
|
||||
from enodia_sentinel.gui import qt_app
|
||||
from enodia_sentinel.gui.model import GuiModel
|
||||
from enodia_sentinel.tray.state import parse_status
|
||||
|
||||
try:
|
||||
from PySide6 import QtCore, QtGui, QtWidgets
|
||||
_HAS_QT = True
|
||||
except ImportError: # pragma: no cover - optional extra
|
||||
_HAS_QT = False
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_QT, "PySide6 not installed")
|
||||
class TestQtGui(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.app = QtWidgets.QApplication.instance()
|
||||
if cls.app is None:
|
||||
cls.app = QtWidgets.QApplication([])
|
||||
|
||||
def _window(self):
|
||||
cfg = Config.load(None)
|
||||
return qt_app._MainWindow(cfg)
|
||||
|
||||
def _destroy(self, win):
|
||||
win.close()
|
||||
win.deleteLater()
|
||||
self.app.processEvents()
|
||||
|
||||
def _model(self):
|
||||
return GuiModel(
|
||||
header=parse_status({
|
||||
"running": True,
|
||||
"total_alerts": 2,
|
||||
"counts": {"HIGH": 1, "MEDIUM": 1},
|
||||
"host": "testhost",
|
||||
"version": "0.7.0",
|
||||
}),
|
||||
status_lines=("Host: testhost", "Daemon: running"),
|
||||
alerts=(
|
||||
("2026-07-10T12:00:00", "HIGH", 100001,
|
||||
"reverse_shell", "bash"),
|
||||
("2026-07-10T12:01:00", "MEDIUM", 100002,
|
||||
"ld_preload", "libevil"),
|
||||
),
|
||||
incidents=(
|
||||
("INC-1", "HIGH", "2026-07-10T12:00:00",
|
||||
"2026-07-10T12:05:00", 2, "reverse_shell"),
|
||||
),
|
||||
posture=(("HIGH", "ssh_root_login", "PermitRootLogin yes"),),
|
||||
integrity_lines=("watchdog.ok: True",),
|
||||
plans=(("INC-1", 2, "freeze and contain"),),
|
||||
plan_details={"INC-1": "Incident: INC-1\nSummary: freeze"},
|
||||
events=("event one", "event two"),
|
||||
)
|
||||
|
||||
def test_window_title(self):
|
||||
win = self._window()
|
||||
self.assertEqual(win.windowTitle(), "Enodia Sentinel")
|
||||
self._destroy(win)
|
||||
|
||||
def test_refresh_renders_model(self):
|
||||
win = self._window()
|
||||
model = self._model()
|
||||
win._on_refreshed(model)
|
||||
self.assertIn("alerts", win.summary_label.text())
|
||||
self.assertIn("testhost", win.status_edit.toPlainText())
|
||||
self.assertIn("Daemon: running", win.status_edit.toPlainText())
|
||||
self.assertEqual(win.alerts_table.rowCount(), 2)
|
||||
self.assertEqual(win.incidents_table.rowCount(), 1)
|
||||
self.assertEqual(win.posture_table.rowCount(), 1)
|
||||
self.assertEqual(win.plans_table.rowCount(), 1)
|
||||
self.assertIn("event two", win.events_edit.toPlainText())
|
||||
self.assertTrue(win.last_refreshed_label.text().startswith("refreshed"))
|
||||
self._destroy(win)
|
||||
|
||||
def test_severity_coloring(self):
|
||||
table = QtWidgets.QTableWidget()
|
||||
table.setColumnCount(2)
|
||||
table.setHorizontalHeaderLabels(["Severity", "Signature"])
|
||||
qt_app._MainWindow._populate_table(None, table, [
|
||||
("CRITICAL", "rootkit"),
|
||||
("HIGH", "reverse_shell"),
|
||||
("MEDIUM", "ld_preload"),
|
||||
])
|
||||
critical = table.item(0, 0)
|
||||
self.assertEqual(
|
||||
critical.background().color().name(), "#ff4444"
|
||||
)
|
||||
high = table.item(1, 0)
|
||||
self.assertEqual(
|
||||
high.background().color().name(), "#ffaa00"
|
||||
)
|
||||
|
||||
def test_plan_selection_shows_detail(self):
|
||||
win = self._window()
|
||||
win._on_refreshed(self._model())
|
||||
win.plans_table.selectRow(0)
|
||||
self.app.processEvents()
|
||||
text = win.plan_edit.toPlainText()
|
||||
self.assertIn("INC-1", text)
|
||||
self.assertIn("freeze", text)
|
||||
self._destroy(win)
|
||||
|
||||
def test_shortcuts_exist(self):
|
||||
win = self._window()
|
||||
shortcuts = [s.key().toString() for s in win.findChildren(QtGui.QShortcut)]
|
||||
self.assertIn("Ctrl+R", shortcuts)
|
||||
self.assertIn("Ctrl+Q", shortcuts)
|
||||
self._destroy(win)
|
||||
|
||||
|
||||
class TestQtMainWithoutQt(unittest.TestCase):
|
||||
def test_main_exits_gracefully_without_qt(self):
|
||||
with patch.object(qt_app, "_HAS_QT", False):
|
||||
with self.assertRaises(SystemExit) as cm:
|
||||
qt_app.main([])
|
||||
self.assertIn("PySide6", str(cm.exception))
|
||||
37
tests/test_gui_qt_imports.py
Normal file
37
tests/test_gui_qt_imports.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Boundary tests for the Qt GUI frontend.
|
||||
|
||||
The shared model must stay GUI-free; only qt_app.py may reference PySide6.
|
||||
"""
|
||||
import re
|
||||
import tomllib
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GUI = ROOT / "enodia_sentinel" / "gui"
|
||||
|
||||
|
||||
class TestQtGuiBoundaries(unittest.TestCase):
|
||||
def test_model_has_no_qt_imports(self):
|
||||
text = (GUI / "model.py").read_text()
|
||||
self.assertFalse(
|
||||
re.search(r"\b(import PySide6|from PySide6)\b", text),
|
||||
"model.py must not import PySide6",
|
||||
)
|
||||
|
||||
def test_tkinter_app_has_no_qt_imports(self):
|
||||
text = (GUI / "app.py").read_text()
|
||||
self.assertNotIn("PySide6", text, "app.py must not import PySide6")
|
||||
|
||||
def test_qt_app_imports_without_runtime_gui_deps(self):
|
||||
# qt_app.py guards PySide6 and is importable even when Qt is absent.
|
||||
from enodia_sentinel.gui import qt_app # noqa: F401
|
||||
|
||||
def test_pyproject_declares_qt_extra_and_script(self):
|
||||
data = tomllib.loads((ROOT / "pyproject.toml").read_text())
|
||||
self.assertIn("qt", data["project"]["optional-dependencies"])
|
||||
self.assertEqual(
|
||||
data["project"]["scripts"]["enodia-sentinel-gui-qt"],
|
||||
"enodia_sentinel.gui.qt_app:main",
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue