# SPDX-License-Identifier: GPL-3.0-or-later import io import json import os import tempfile import unittest from contextlib import redirect_stdout from pathlib import Path from enodia_sentinel.cli import main from enodia_sentinel.config import Config from enodia_sentinel.tui import ( TUI_COMMANDS, VIEWS, TuiState, _header_text, collect_model, complete_command, render_lines, run_tui_command, ) class TestTuiCore(unittest.TestCase): def setUp(self): self.dir = tempfile.TemporaryDirectory() self.tmp = Path(self.dir.name) self.cfg = Config() self.cfg.log_dir = self.tmp def tearDown(self): self.dir.cleanup() def test_command_completion_unique_and_common_prefix(self): self.assertEqual(complete_command("inc"), "incidents") self.assertEqual(complete_command("resp"), "response") self.assertEqual(complete_command("r"), "r") self.assertEqual(complete_command("zzz"), "zzz") def test_run_tui_command_switches_views_and_filters(self): state = TuiState() for view in VIEWS: if view == "help": continue with self.subTest(view=view): run_tui_command(state, view) self.assertEqual(state.view, view) run_tui_command(state, "filter ssh") self.assertEqual(state.filter_text, "ssh") run_tui_command(state, "clear") self.assertEqual(state.filter_text, "") run_tui_command(state, "quit") self.assertTrue(state.should_quit) def test_collect_model_contains_dashboard_parity_sections(self): model = collect_model(self.cfg) for key in ( "status", "alerts", "incidents", "timeline", "posture", "integrity", "rules", "events", "response", ): self.assertIn(key, model) state = TuiState(model=model) lines = render_lines(state, width=80) self.assertTrue(any("Daemon:" in line for line in lines)) self.assertTrue(any("Heartbeat:" in line for line in lines)) def test_render_alerts_and_filter(self): (self.tmp / "alert-1.json").write_text(json.dumps({ "time": "2026-07-09T01:02:03-07:00", "alerts": [{ "severity": "CRITICAL", "sid": 100010, "signature": "reverse_shell", "detail": "bash connected to 8.8.8.8", }], })) state = TuiState(view="alerts", model=collect_model(self.cfg)) self.assertTrue(any("reverse_shell" in line for line in render_lines(state))) state.filter_text = "nomatch" self.assertEqual(render_lines(state), ["No rows match filter: nomatch"]) def test_render_posture_integrity_events_and_response_views(self): model = { "posture": { "count": 1, "counts": {"HIGH": 1}, "findings": [{ "severity": "HIGH", "sid": 100040, "signature": "ssh_root_login", "detail": "PermitRootLogin yes", }], }, "timeline": [{ "incident": { "id": "inc-1", "severity": "CRITICAL", "alert_count": 1, "signatures": ["reverse_shell"], }, "timeline": [{ "time": "2026-07-09T01:02:03-07:00", "severity": "CRITICAL", "signatures": ["reverse_shell"], "pids": [4242], }], }], "integrity": { "status": "review", "generated_at": 1.0, "checks": {"watchdog": "ok", "fim_baseline": "missing"}, "watchdog": { "ok": True, "message": "heartbeat fresh", "heartbeat_age": 2, "max_age": 120, "daemon_running": True, }, "anchors": { "fim_baseline": {"status": "missing", "path": "/tmp/fim.json"}, "pkgdb": {"status": "ok", "fingerprint_prefix": "abc123"}, "pacman": {"keyring_present": True, "siglevel_status": "ok"}, }, "sentinel_footprint": {"present": 2, "configured": 3, "missing": 1}, "reconciliation": { "total": 1, "stale": 1, "stale_items": [{ "kind": "fim", "target": "/etc/passwd", "reason": "ttl expired", }], }, }, "events": ["2026-07-09T01:02:03 event happened"], "response": [{ "incident_id": "inc-1", "mode": "dry-run", "summary": { "severity": "CRITICAL", "signatures": ["reverse_shell"], "action_count": 1, }, "actions": [{ "id": "A001", "category": "evidence", "risk": "low", "title": "Freeze Sentinel evidence bundle", "command": ["tar", "-czf", "/tmp/evidence.tgz"], }], }], } posture = render_lines(TuiState(view="posture", model=model), width=100) self.assertTrue(any("ssh_root_login" in line for line in posture)) timeline = render_lines(TuiState(view="timeline", model=model), width=100) self.assertTrue(any("inc-1" in line for line in timeline)) self.assertTrue(any("pids=4242" in line for line in timeline)) integrity = render_lines(TuiState(view="integrity", model=model), width=100) self.assertTrue(any("Overall:" in line for line in integrity)) self.assertTrue(any("ttl expired" in line for line in integrity)) events = render_lines(TuiState(view="events", model=model), width=100) self.assertEqual(events, ["2026-07-09T01:02:03 event happened"]) response = render_lines(TuiState(view="response", model=model), width=100) self.assertTrue(any("inc-1" in line for line in response)) self.assertTrue(any("tar -czf /tmp/evidence.tgz" in line for line in response)) def test_cli_completion_outputs_scripts(self): for shell in ("bash", "zsh"): with self.subTest(shell=shell): buf = io.StringIO() with redirect_stdout(buf): code = main(["completion", shell]) self.assertEqual(code, 0) self.assertIn("enodia-sentinel", buf.getvalue()) self.assertIn("tui", buf.getvalue()) self.assertIn("apply", buf.getvalue()) self.assertIn("--dry-run", buf.getvalue()) def test_help_lists_all_commands(self): help_lines = render_lines(TuiState(view="help"), width=100) for command in TUI_COMMANDS: self.assertTrue(any(command in line for line in help_lines), command) def test_header_scales_to_terminal_width(self): state = TuiState(view="integrity") for width in (60, 100, 180): with self.subTest(width=width): header = _header_text(state, width) self.assertLessEqual(len(header), width - 1) self.assertIn("view=integrity", _header_text(state, 60)) self.assertIn("6 Int", _header_text(state, 100)) self.assertIn("6 Integrity", _header_text(state, 180)) if __name__ == "__main__": unittest.main()