Files
fgAI/tests/test_monitor.py

320 lines
16 KiB
Python

import tempfile
import json
import unittest
from pathlib import Path
from unittest.mock import patch
from fgai.history import StatusSnapshotStore
from fgai.history import FieldDiscoveryStore
from fgai.monitor import add_llm_assessment, build_status, cached_status_with_error, write_refreshing_status, write_status
class MonitorTests(unittest.TestCase):
def test_build_status_from_log_file(self):
with tempfile.TemporaryDirectory() as tmp:
log_path = Path(tmp) / "fg.log"
log_path.write_text(
"\n".join(
[
'type=utm subtype=ips srcip=8.8.8.8 dstip=10.0.0.10 service=https action=blocked severity=critical',
'type=utm subtype=ips srcip=8.8.8.8 dstip=10.0.0.10 service=https action=blocked severity=critical',
'type=utm subtype=ips srcip=8.8.8.8 dstip=10.0.0.10 service=https action=blocked severity=critical',
]
),
encoding="utf-8",
)
status = build_status(str(log_path), incident_path=str(Path(tmp) / "incidents.json"))
self.assertEqual(status["summary"]["total"], 3)
self.assertEqual(status["status_schema"], 2)
self.assertFalse(status["stale"])
self.assertFalse(status["status_cache"]["served_from_cache"])
self.assertGreaterEqual(len(status["anomalies"]), 1)
def test_write_status_creates_parent_directory(self):
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "state" / "status.json"
write_status({"ok": True}, str(output))
self.assertTrue(output.exists())
def test_write_refreshing_status_clears_stale_mcp_error(self):
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "status.json"
output.write_text(
json.dumps(
{
"status_schema": 2,
"stale": True,
"stale_reason": "live_mcp_error",
"capabilities": {"graylog_mcp": {"status": "error", "error": "old dns error", "events_fetched": 99, "aggregate_events": 1234, "fetch_mode": "aggregate"}},
}
),
encoding="utf-8",
)
write_refreshing_status(str(output), call_timeout_seconds=8, poll_timeout_seconds=120, runtime_values={"log_source": "graylog_mcp", "graylog_streams": [{"id": "fw", "title": "Firewall", "enabled": True}]})
status = json.loads(output.read_text(encoding="utf-8"))
self.assertFalse(status["stale"])
self.assertEqual(status["stale_reason"], "")
self.assertEqual(status["capabilities"]["graylog_mcp"]["status"], "refreshing")
self.assertEqual(status["capabilities"]["graylog_mcp"]["previous_status"], "error")
self.assertEqual(status["capabilities"]["graylog_mcp"]["previous_error"], "old dns error")
self.assertEqual(status["capabilities"]["graylog_mcp"]["previous_events_fetched"], 99)
self.assertEqual(status["capabilities"]["graylog_mcp"]["previous_aggregate_events"], 1234)
self.assertEqual(status["capabilities"]["graylog_mcp"]["previous_fetch_mode"], "aggregate")
self.assertEqual(status["capabilities"]["graylog_mcp"]["call_timeout_seconds"], 8)
self.assertEqual(status["capabilities"]["graylog_mcp"]["poll_timeout_seconds"], 120)
self.assertEqual(status["configuration"]["enabled_streams"], 1)
self.assertEqual(status["stream_coverage"][0]["stream_name"], "Firewall")
self.assertFalse(status["status_cache"]["served_from_cache"])
def test_write_refreshing_status_uses_last_good_cache(self):
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "status.json"
cache_path = str(Path(tmp) / "status-cache.sqlite3")
output.write_text(
json.dumps({"capabilities": {"graylog_mcp": {"status": "refreshing", "events_fetched": 0, "aggregate_events": 0}}}),
encoding="utf-8",
)
StatusSnapshotStore(cache_path).save(
"last_good",
{
"summary": {"total": 1234},
"capabilities": {"graylog_mcp": {"status": "connected", "events_fetched": 99, "raw_events_fetched": 88, "aggregate_events": 1234, "poll_completed_at": 123456, "fetch_mode": "aggregate", "coverage_status": "complete_window"}},
"cross_source_correlations": [{"entity": "10.0.0.1", "entity_label": "host01 (10.0.0.1)"}],
"llm_assessment": {"enabled": True, "status": "cached", "text": "previous assessment"},
},
)
write_refreshing_status(str(output), cache_path=cache_path)
status = json.loads(output.read_text(encoding="utf-8"))
mcp = status["capabilities"]["graylog_mcp"]
self.assertEqual(status["summary"]["total"], 1234)
self.assertEqual(mcp["status"], "refreshing")
self.assertEqual(mcp["events_fetched"], 99)
self.assertEqual(mcp["raw_events_fetched"], 88)
self.assertEqual(mcp["aggregate_events"], 1234)
self.assertEqual(mcp["previous_status"], "connected")
self.assertGreater(mcp["poll_started_at"], 123456)
self.assertEqual(mcp["previous_poll_completed_at"], 123456)
self.assertTrue(status["llm_assessment"]["enabled"])
self.assertTrue(status["status_cache"]["served_from_cache"])
def test_profile_readiness_includes_stream_and_profile_names(self):
with tempfile.TemporaryDirectory() as tmp:
config_path = Path(tmp) / "config.json"
config_path.write_text(
json.dumps(
{
"graylog_streams": [{"id": "66fe", "title": "Fortigate", "enabled": True}],
"graylog_stream_profiles": [{"stream_id": "66fe", "name": "66fe", "entity_field": "srcip", "categorical_fields": ["action"]}],
}
),
encoding="utf-8",
)
with patch("fgai.monitor.BaselineStore.profile_readiness", return_value=[{"stream_id": "66fe", "field": "action", "buckets": 12, "ready": True}]):
status = build_status(str(Path(tmp) / "missing.log"), baseline_path=str(Path(tmp) / "baseline.sqlite3"), config_path=str(config_path), incident_path=str(Path(tmp) / "incidents.json"))
self.assertEqual(status["profile_readiness"][0]["stream_name"], "Fortigate")
self.assertEqual(status["profile_readiness"][0]["profile_name"], "Fortigate profile")
self.assertEqual(status["stream_profiles"][0]["name"], "Fortigate profile")
def test_stream_coverage_marks_missing_profiles(self):
with tempfile.TemporaryDirectory() as tmp:
config_path = Path(tmp) / "config.json"
config_path.write_text(
json.dumps(
{
"graylog_streams": [
{"id": "firewall", "title": "Firewall", "enabled": True},
{"id": "dns", "title": "DNS", "enabled": True},
],
"graylog_stream_profiles": [{"stream_id": "firewall", "name": "Firewall profile", "entity_field": "srcip", "categorical_fields": ["action"]}],
}
),
encoding="utf-8",
)
with patch("fgai.monitor.BaselineStore.profile_readiness", return_value=[{"stream_id": "firewall", "field": "action", "buckets": 12, "ready": True}]):
status = build_status(str(Path(tmp) / "missing.log"), baseline_path=str(Path(tmp) / "baseline.sqlite3"), config_path=str(config_path), incident_path=str(Path(tmp) / "incidents.json"))
coverage = {item["stream_id"]: item for item in status["stream_coverage"]}
self.assertEqual(coverage["firewall"]["health"], "no_events")
self.assertEqual(coverage["firewall"]["readiness"], "1/1")
self.assertEqual(coverage["dns"]["health"], "missing_profile")
def test_graylog_mcp_without_enabled_streams_reports_clear_status(self):
with tempfile.TemporaryDirectory() as tmp:
config_path = Path(tmp) / "config.json"
config_path.write_text(
json.dumps(
{
"log_source": "graylog_mcp",
"graylog_mcp_url": "https://graylog.example/api/mcp",
"graylog_mcp_token": "token",
"graylog_streams": [{"id": "disabled", "title": "Disabled", "enabled": False}],
}
),
encoding="utf-8",
)
status = build_status(str(Path(tmp) / "missing.log"), config_path=str(config_path), incident_path=str(Path(tmp) / "incidents.json"))
self.assertEqual(status["capabilities"]["graylog_mcp"]["status"], "no_streams_enabled")
self.assertEqual(status["summary"]["total"], 0)
def test_add_llm_assessment_records_error_without_ollama(self):
status = {"summary": {}, "anomalies": []}
with patch("fgai.monitor.ollama_dashboard_assessment", side_effect=TimeoutError("timeout")):
add_llm_assessment(status, previous="old text")
self.assertEqual(status["llm_assessment"]["status"], "error")
self.assertEqual(status["llm_assessment"]["text"], "old text")
def test_add_llm_assessment_records_text(self):
status = {"summary": {}, "anomalies": []}
with patch("fgai.monitor.ollama_dashboard_assessment", return_value="looks noisy"):
add_llm_assessment(status)
self.assertEqual(status["llm_assessment"]["status"], "ok")
self.assertEqual(status["llm_assessment"]["text"], "looks noisy")
def test_profile_advisor_status_records_error(self):
with tempfile.TemporaryDirectory() as tmp:
log_path = Path(tmp) / "events.log"
log_path.write_text("fgai_stream_id=windows fgai_stream=Windows username=alice eventid=4625 action=failure\n", encoding="utf-8")
config_path = Path(tmp) / "config.json"
config_path.write_text(json.dumps({"profile_advisor_enabled": True, "profile_advisor_model": "qwen3:8b"}), encoding="utf-8")
with patch("fgai.monitor.ollama_profile_advice", side_effect=TimeoutError("timeout")):
status = build_status(str(log_path), config_path=str(config_path), incident_path=str(Path(tmp) / "incidents.json"))
advisor = status["capabilities"]["profile_advisor"]
self.assertEqual(advisor["status"], "error")
self.assertIn("timeout", advisor["error"])
self.assertEqual(status["profile_suggestions"][0]["profile_advisor"]["status"], "heuristic")
self.assertIn("timeout", status["profile_suggestions"][0]["profile_advisor"]["error"])
def test_profile_advisor_skips_when_all_streams_have_profiles(self):
with tempfile.TemporaryDirectory() as tmp:
log_path = Path(tmp) / "events.log"
log_path.write_text("fgai_stream_id=windows fgai_stream=Windows username=alice\n", encoding="utf-8")
config_path = Path(tmp) / "config.json"
config_path.write_text(
json.dumps(
{
"profile_advisor_enabled": True,
"graylog_stream_profiles": [{"stream_id": "windows", "name": "Windows profile", "entity_field": "username", "entity_fields": ["username"], "timestamp_field": "timestamp"}],
}
),
encoding="utf-8",
)
with patch("fgai.monitor.ollama_profile_advice") as advisor:
status = build_status(str(log_path), config_path=str(config_path), incident_path=str(Path(tmp) / "incidents.json"))
advisor.assert_not_called()
self.assertEqual(status["capabilities"]["profile_advisor"]["status"], "skipped_no_profile_changes")
def test_profile_advisor_runs_when_existing_profile_has_new_fields(self):
with tempfile.TemporaryDirectory() as tmp:
log_path = Path(tmp) / "events.log"
log_path.write_text("fgai_stream_id=windows fgai_stream=Windows username=alice eventid=4625 action=failure\n", encoding="utf-8")
config_path = Path(tmp) / "config.json"
config_path.write_text(
json.dumps(
{
"profile_advisor_enabled": True,
"graylog_stream_profiles": [{"stream_id": "windows", "name": "Windows profile", "entity_field": "username", "entity_fields": ["username"], "timestamp_field": "timestamp"}],
}
),
encoding="utf-8",
)
with patch("fgai.monitor.ollama_profile_advice", return_value=[]) as advisor:
status = build_status(str(log_path), config_path=str(config_path), incident_path=str(Path(tmp) / "incidents.json"))
advisor.assert_called_once()
self.assertEqual(status["capabilities"]["profile_advisor"]["status"], "empty")
def test_profile_suggestions_use_cached_discovered_fields(self):
with tempfile.TemporaryDirectory() as tmp:
history_path = str(Path(tmp) / "history.sqlite3")
incidents = str(Path(tmp) / "incidents.json")
first = Path(tmp) / "first.log"
first.write_text(
"\n".join(
f"fgai_stream_id=app fgai_stream=App lcs_actor=user{index % 4} lcs_result=r{index % 3} action=ok"
for index in range(1, 20)
),
encoding="utf-8",
)
build_status(str(first), history_path=history_path, incident_path=incidents)
second = Path(tmp) / "second.log"
second.write_text(
"\n".join(
f"fgai_stream_id=app fgai_stream=App action=ok"
for _index in range(1, 5)
),
encoding="utf-8",
)
status = build_status(str(second), history_path=history_path, incident_path=incidents)
suggestion = status["profile_suggestions"][0]
self.assertIn("lcs_actor", suggestion["entity_fields"])
self.assertIn("lcs_result", suggestion["categorical_fields"])
self.assertGreater(status["baseline"]["discovery_cache_events"], 0)
def test_field_catalog_creates_discovery_events_without_raw_values(self):
with tempfile.TemporaryDirectory() as tmp:
store = FieldDiscoveryStore(str(Path(tmp) / "history.sqlite3"))
store.ingest_catalog(
"app",
"App",
[
{"name": "lcs_customer_id", "type": {"type": "string", "properties": ["enumerable"]}},
{"name": "lcs_duration_ms", "type": {"type": "long", "properties": ["numeric"]}},
],
)
events = store.synthetic_events()
fields = {field for event in events for field in event.fields}
self.assertIn("lcs_customer_id", fields)
self.assertIn("lcs_duration_ms", fields)
def test_cached_status_with_error_keeps_last_good_dashboard_data(self):
with tempfile.TemporaryDirectory() as tmp:
cache_path = str(Path(tmp) / "status-cache.sqlite3")
StatusSnapshotStore(cache_path).save(
"last_good",
{
"summary": {"total": 42},
"capabilities": {"graylog_mcp": {"status": "connected"}},
"stream_coverage": [{"stream_name": "Firewall", "aggregate_status": "error", "error": "old aggregate error"}],
"cross_source_correlations": [{"entity": "10.0.0.1"}],
},
)
status = cached_status_with_error(cache_path, {"status": "error", "error": "mcp down"})
self.assertIsNotNone(status)
self.assertEqual(status["summary"]["total"], 42)
self.assertEqual(status["status_schema"], 2)
self.assertTrue(status["stale"])
self.assertEqual(status["capabilities"]["graylog_mcp"]["status"], "error")
self.assertTrue(status["status_cache"]["served_from_cache"])
self.assertEqual(status["stream_coverage"][0]["aggregate_error"], "old aggregate error")
self.assertEqual(status["stream_coverage"][0]["raw_error"], "")
if __name__ == "__main__":
unittest.main()