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
127 lines
4.4 KiB
Python
127 lines
4.4 KiB
Python
# 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))
|