enodia-sentinal/tests/test_tui.py

233 lines
9.3 KiB
Python

# 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_beginner_workflow_commands(self):
state = TuiState()
run_tui_command(state, "next")
self.assertEqual(state.view, "incidents")
run_tui_command(state, "next")
self.assertEqual(state.view, "timeline")
run_tui_command(state, "back")
self.assertEqual(state.view, "incidents")
run_tui_command(state, "home")
self.assertEqual(state.view, "status")
run_tui_command(state, "guide")
self.assertEqual(state.view, "help")
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))
self.assertTrue(any("Plain language:" in line for line in lines))
self.assertTrue(any("fresh 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"],
"correlations": [{
"sid": 100080,
"severity": "CRITICAL",
"signature": "correlation.multi_stage_intrusion",
"summary": "web RCE plus suspicious egress",
}],
},
"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},
"hash_chain": {
"path": "/tmp/hash-chain.jsonl",
"exists": True,
"count": 2,
"last_hash": "abc123",
},
"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))
self.assertTrue(any("correlation sid=100080" 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("Hash chain:" 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.assertTrue(any("technical logs" in line for line in events))
self.assertTrue(any("2026-07-09T01:02:03 event happened" in line
for line in events))
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))
self.assertTrue(any("not executed" 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)
self.assertTrue(any("New User Workflow" in line for line in help_lines))
self.assertTrue(any("read-only" in line for line in help_lines))
self.assertTrue(any("n/b" in line for line in help_lines))
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("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()