chore(home): mirror from kalinamall (9883e6a) with papatramp URLs

This commit is contained in:
2026-07-14 20:43:52 +10:00
commit ed4e78f6c3
312 changed files with 42790 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
"""SQLite in-memory fixtures for API tests."""
import os
# Must be set before app.database imports create_engine
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("SAC_BOOTSTRAP_API_KEY", "sac_test_key_for_pytest_only")
os.environ.setdefault("SAC_SECURITY_ENFORCE", "false")
os.environ.setdefault("JWT_SECRET", "pytest-jwt-secret-not-for-production-use")
os.environ.setdefault("SAC_SSH_AUTO_ADD_HOST_KEY", "true")
os.environ.setdefault("SAC_HOST_SILENCE_SCAN_ENABLED", "false")
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import JSON, create_engine, event
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.auth.api_key import hash_api_key
from app.auth.jwt_auth import create_access_token
from app.database import Base, get_db
from app.main import app as fastapi_app
import app.models # noqa: F401 — register all tables on Base.metadata
from app.models import ApiKey, User
from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MONITOR
from app.services.user_auth import hash_password
TEST_API_KEY = os.environ["SAC_BOOTSTRAP_API_KEY"]
@event.listens_for(Base.metadata, "before_create")
def _sqlite_jsonb_as_json(metadata, connection, **_kwargs) -> None:
if connection.dialect.name != "sqlite":
return
for table in metadata.tables.values():
for column in table.columns:
if isinstance(column.type, JSONB):
column.type = JSON()
@pytest.fixture
def db_engine():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def db_session(db_engine):
Session = sessionmaker(bind=db_engine, autocommit=False, autoflush=False)
session = Session()
session.add(
ApiKey(
name="test",
key_prefix=TEST_API_KEY[:12],
key_hash=hash_api_key(TEST_API_KEY),
is_active=True,
)
)
session.add(
User(
username="test-admin",
password_hash=hash_password("test-admin-password"),
role=USER_ROLE_ADMIN,
is_active=True,
)
)
session.add(
User(
username="test-monitor",
password_hash=hash_password("test-monitor-password"),
role=USER_ROLE_MONITOR,
is_active=True,
)
)
session.commit()
yield session
session.close()
@pytest.fixture(autouse=True)
def mock_agent_git_release(monkeypatch):
"""API tests must not clone real git repos."""
from app.services.agent_git_release import GitReleaseVersions
empty = GitReleaseVersions(versions={}, fetched_at=None, from_cache=True)
monkeypatch.setattr(
"app.services.agent_git_release.get_git_release_versions",
lambda cfg, **kwargs: empty,
)
monkeypatch.setattr(
"app.api.v1.hosts.get_git_release_versions",
lambda cfg, **kwargs: empty,
)
monkeypatch.setattr(
"app.api.v1.settings.get_git_release_versions",
lambda cfg, **kwargs: empty,
)
monkeypatch.setattr(
"app.services.agent_update.get_git_release_versions",
lambda cfg, **kwargs: empty,
)
@pytest.fixture(autouse=True)
def reset_remote_action_state():
from app.services import host_remote_actions
with host_remote_actions._lock:
host_remote_actions._running.clear()
yield
with host_remote_actions._lock:
host_remote_actions._running.clear()
@pytest.fixture
def client(db_session, db_engine, monkeypatch):
monkeypatch.setenv("SAC_REMOTE_ACTION_INLINE", "1")
monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None)
monkeypatch.setattr("app.main.bootstrap_users", lambda: None)
monkeypatch.setattr("app.main.bootstrap_stale_remote_actions", lambda: None)
test_session_local = sessionmaker(bind=db_engine, autocommit=False, autoflush=False)
monkeypatch.setattr("app.services.host_remote_actions.SessionLocal", test_session_local)
def override_get_db():
session = test_session_local()
try:
yield session
finally:
session.close()
fastapi_app.dependency_overrides[get_db] = override_get_db
with TestClient(fastapi_app) as c:
yield c
fastapi_app.dependency_overrides.clear()
@pytest.fixture
def auth_headers():
return {"Authorization": f"Bearer {TEST_API_KEY}"}
@pytest.fixture
def jwt_headers():
token = create_access_token("test-admin", USER_ROLE_ADMIN)
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def jwt_monitor_headers():
token = create_access_token("test-monitor", USER_ROLE_MONITOR)
return {"Authorization": f"Bearer {token}"}
+86
View File
@@ -0,0 +1,86 @@
"""Tests for git-based agent release version resolution."""
from pathlib import Path
from app.services.agent_git_release import (
normalize_git_repo_url,
parse_product_version_from_dir,
parse_rdp_version_from_files,
parse_ssh_version_from_files,
recommended_from_git,
)
from app.services.agent_update_settings import PRODUCT_RDP, PRODUCT_SSH, AgentUpdateConfig
from app.services.agent_version import reference_agent_versions_by_product
def test_normalize_git_repo_url():
assert normalize_git_repo_url("git.papatramp.ru/PapaTramp/ssh-monitor").endswith(
"ssh-monitor.git"
)
assert normalize_git_repo_url("https://example.com/repo.git") == "https://example.com/repo.git"
def test_parse_rdp_version_from_files():
assert parse_rdp_version_from_files("2.1.2-SAC\n", None) == "2.1.2-SAC"
assert (
parse_rdp_version_from_files(None, '$ScriptVersion = "2.0.35-SAC"')
== "2.0.35-SAC"
)
def test_parse_ssh_version_from_files():
assert parse_ssh_version_from_files('SSH_MONITOR_VERSION="2.1.0-SAC"\n', None) == "2.1.0-SAC"
assert parse_ssh_version_from_files(None, "2.0.6-SAC") == "2.0.6-SAC"
def test_parse_product_version_from_dir(tmp_path: Path):
(tmp_path / "version.txt").write_text("2.1.2-SAC\n", encoding="utf-8")
assert parse_product_version_from_dir(tmp_path, PRODUCT_RDP) == "2.1.2-SAC"
ssh_dir = tmp_path / "ssh"
ssh_dir.mkdir()
(ssh_dir / "ssh-monitor").write_text('SSH_MONITOR_VERSION="2.1.0-SAC"\n', encoding="utf-8")
assert parse_product_version_from_dir(ssh_dir, PRODUCT_SSH) == "2.1.0-SAC"
def test_recommended_from_git_prefers_git():
cfg = AgentUpdateConfig(
mode="sac",
fallback_enabled=True,
fallback_after_minutes=15,
recommended_rdp_version="2.0.0-SAC",
recommended_ssh_version="",
min_rdp_version="",
min_ssh_version="",
win_agent_update_script="",
rdp_git_repo_url="https://git.example/rdp.git",
ssh_git_repo_url="https://git.example/ssh.git",
git_branch="main",
source="env",
)
git_versions = {PRODUCT_SSH: "2.1.0-SAC"}
assert recommended_from_git(cfg, git_versions, PRODUCT_SSH) == "2.1.0-SAC"
assert recommended_from_git(cfg, git_versions, PRODUCT_RDP) == "2.0.0-SAC"
def test_reference_agent_versions_uses_git():
cfg = AgentUpdateConfig(
mode="gpo",
fallback_enabled=True,
fallback_after_minutes=15,
recommended_rdp_version="",
recommended_ssh_version="",
min_rdp_version="",
min_ssh_version="",
win_agent_update_script="",
rdp_git_repo_url="",
ssh_git_repo_url="",
git_branch="main",
source="env",
)
refs = reference_agent_versions_by_product(
cfg,
{"ssh-monitor": "2.0.6-SAC"},
git_versions={"ssh-monitor": "2.1.0-SAC"},
)
assert refs["ssh-monitor"] == "2.1.0-SAC"
+292
View File
@@ -0,0 +1,292 @@
"""Tests for agent update settings, poll, request and ingest lifecycle."""
import time
import uuid
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from app.models import Host
from app.services.agent_poll import build_agent_poll_payload
from app.services.agent_update import (
AGENT_UPDATE_SUCCESS,
AgentUpdateNotAllowedError,
process_agent_update_ingest,
request_agent_update,
)
from app.services.agent_update_settings import upsert_agent_update_settings
from app.services.ingest import ingest_event
from tests.test_ingest import VALID_EVENT
def wait_remote_job(client, host_id, headers, timeout=5.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
response = client.get(
f"/api/v1/hosts/{host_id}/actions/remote-job",
headers=headers,
)
assert response.status_code == 200
body = response.json()
if not body.get("active") and body.get("status") != "running":
return body
time.sleep(0.05)
raise AssertionError("remote job did not finish in time")
def test_agent_update_settings_sac_mode(db_session):
cfg = upsert_agent_update_settings(
db_session,
mode="sac",
recommended_ssh_version="2.1.0-SAC",
fallback_after_minutes=10,
)
assert cfg.mode == "sac"
assert cfg.recommended_ssh_version == "2.1.0-SAC"
assert cfg.fallback_after_minutes == 10
def test_request_agent_update_sets_pending(db_session):
upsert_agent_update_settings(db_session, mode="sac", recommended_ssh_version="2.1.0-SAC")
host = Host(
hostname="linux-1",
os_family="linux",
product="ssh-monitor",
product_version="2.0.0-SAC",
agent_instance_id="agent-uuid-1",
)
db_session.add(host)
db_session.commit()
request_agent_update(db_session, host)
assert host.pending_agent_update is True
assert host.pending_update_target_version == "2.1.0-SAC"
assert host.agent_update_state == "requested"
def test_request_agent_update_blocked_in_gpo_mode(db_session):
upsert_agent_update_settings(db_session, mode="gpo")
host = Host(
hostname="linux-2",
os_family="linux",
product="ssh-monitor",
agent_instance_id="agent-uuid-2",
)
db_session.add(host)
db_session.commit()
try:
request_agent_update(db_session, host)
raise AssertionError("expected AgentUpdateNotAllowedError")
except AgentUpdateNotAllowedError:
pass
def test_poll_payload_includes_update_and_config(db_session):
upsert_agent_update_settings(db_session, mode="sac")
host = Host(
hostname="win-1",
os_family="windows",
product="rdp-login-monitor",
agent_instance_id="agent-uuid-3",
pending_agent_update=True,
pending_update_target_version="2.1.0-SAC",
agent_config={"ServerDisplayName": "Test PC"},
agent_config_revision=3,
)
db_session.add(host)
db_session.commit()
payload = build_agent_poll_payload(db_session, host)
assert payload["config_revision"] == 3
assert payload["config"]["settings"]["ServerDisplayName"] == "Test PC"
assert payload["update"]["requested"] is True
assert payload["update"]["target_version"] == "2.1.0-SAC"
assert payload["update"]["source"] == "sac"
def test_ingest_agent_update_success_clears_pending(db_session):
host = Host(
hostname="linux-3",
os_family="linux",
product="ssh-monitor",
agent_instance_id="agent-uuid-4",
pending_agent_update=True,
pending_update_target_version="2.1.0-SAC",
)
db_session.add(host)
db_session.commit()
process_agent_update_ingest(
db_session,
host,
AGENT_UPDATE_SUCCESS,
{"product_version": "2.1.0-SAC"},
)
assert host.pending_agent_update is False
assert host.product_version == "2.1.0-SAC"
assert host.agent_update_state == "success"
def test_api_request_agent_update(jwt_headers, client, db_session):
upsert_agent_update_settings(db_session, mode="sac", recommended_ssh_version="2.1.0-SAC")
host = Host(
hostname="api-linux",
os_family="linux",
product="ssh-monitor",
agent_instance_id="agent-api-1",
)
db_session.add(host)
db_session.commit()
response = client.post(f"/api/v1/hosts/{host.id}/actions/request-agent-update", headers=jwt_headers)
assert response.status_code == 200
body = response.json()
assert body["pending_agent_update"] is True
assert body["target_version"] == "2.1.0-SAC"
def test_api_agent_poll_extended(auth_headers, client, db_session):
upsert_agent_update_settings(db_session, mode="sac")
host = Host(
hostname="poll-host",
os_family="linux",
product="ssh-monitor",
agent_instance_id="poll-agent-id",
pending_agent_update=True,
pending_update_target_version="2.1.0-SAC",
)
db_session.add(host)
db_session.commit()
response = client.get(
"/api/v1/agent/commands",
params={"agent_instance_id": "poll-agent-id"},
headers=auth_headers,
)
assert response.status_code == 200
body = response.json()
assert body["update"]["requested"] is True
assert body["commands"] == []
def test_api_patch_agent_config(jwt_headers, client, db_session):
host = Host(
hostname="cfg-host",
os_family="windows",
product="rdp-login-monitor",
)
db_session.add(host)
db_session.commit()
response = client.patch(
f"/api/v1/hosts/{host.id}/agent-config",
json={"settings": {"ServerDisplayName": "UNMS Kalina", "GetInventory": True}},
headers=jwt_headers,
)
assert response.status_code == 200
body = response.json()
assert body["config_revision"] == 1
assert body["settings"]["ServerDisplayName"] == "UNMS Kalina"
def test_api_agent_update_fallback_ssh(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
host = Host(
hostname="fb-linux",
os_family="linux",
product="ssh-monitor",
ipv4="10.10.36.9",
)
db_session.add(host)
db_session.commit()
with patch("app.services.agent_update.run_ssh_monitor_update") as mock_update:
from app.services.ssh_connect import SshCommandResult
mock_update.return_value = SshCommandResult(
ok=True,
message="updated",
target="fb-linux",
agent_version="2.1.0-SAC",
)
response = client.post(
f"/api/v1/hosts/{host.id}/actions/agent-update-fallback",
headers=jwt_headers,
)
assert response.status_code == 202
assert response.json()["status"] == "running"
job = wait_remote_job(client, host.id, jwt_headers)
assert job["ok"] is True
assert job["product_version"] == "2.1.0-SAC"
def test_clear_stale_running_remote_actions(db_session):
from app.services.host_remote_actions import clear_stale_running_remote_actions
host = Host(
hostname="stale-win",
os_family="windows",
product="rdp-login-monitor",
agent_update_state="running",
remote_action={"status": "running", "message": "Подключение…"},
)
db_session.add(host)
db_session.commit()
cleared = clear_stale_running_remote_actions(db_session, reason="test reset")
assert cleared == ["stale-win"]
db_session.refresh(host)
assert host.agent_update_state == "failed"
assert host.remote_action["status"] == "failed"
assert host.remote_action["ok"] is False
def test_get_remote_action_status_ignores_stale_running_payload(db_session):
from app.services.host_remote_actions import get_remote_action_status
host = Host(
hostname="done-linux",
os_family="linux",
product="ssh-monitor",
agent_update_state="success",
remote_action={
"status": "running",
"message": "Выполняется обновление… (лог с хоста)",
"output": "=== Script update completed successfully ===",
"ok": True,
"finished_at": "2026-07-08T02:21:11+00:00",
},
)
db_session.add(host)
db_session.commit()
status = get_remote_action_status(host)
assert status["active"] is False
assert status["agent_update_state"] == "success"
def test_cancel_host_remote_job_api(jwt_headers, client, db_session):
host = Host(
hostname="cancel-me",
os_family="windows",
product="rdp-login-monitor",
agent_update_state="running",
remote_action={"status": "running", "title": "Обновление через WinRM"},
)
db_session.add(host)
db_session.commit()
response = client.post(
f"/api/v1/hosts/{host.id}/actions/remote-job/cancel",
headers=jwt_headers,
)
assert response.status_code == 200
body = response.json()
assert body["active"] is False
assert body["status"] == "failed"
+129
View File
@@ -0,0 +1,129 @@
"""Agent version parse/compare for outdated highlighting."""
from app.services.agent_version import (
effective_reference_version,
host_version_outdated,
is_agent_version_outdated,
latest_agent_versions_by_product,
parse_agent_version,
)
from app.models import Host
from datetime import datetime, timezone
def test_parse_agent_version():
assert parse_agent_version("2.0.25-SAC") == parse_agent_version("2.0.25")
assert parse_agent_version("bad") is None
assert parse_agent_version(None) is None
def test_outdated_same_minor_patch_lag():
latest = "2.0.25-SAC"
assert is_agent_version_outdated("2.0.25-SAC", latest) is False
assert is_agent_version_outdated("2.0.24-SAC", latest) is False
assert is_agent_version_outdated("2.0.23-SAC", latest) is False
assert is_agent_version_outdated("2.0.22-SAC", latest) is True
assert is_agent_version_outdated("2.0.18-SAC", latest) is True
assert is_agent_version_outdated("2.0.20-SAC", latest) is True
def test_outdated_different_minor_or_major():
latest = "2.0.1-SAC"
assert is_agent_version_outdated("1.2.5-SAC", latest) is True
assert is_agent_version_outdated("2.1.0-SAC", latest) is False
def test_effective_reference_version_picks_max():
assert (
effective_reference_version(
recommended="2.1.0-SAC",
fleet_latest="2.0.6-SAC",
min_version="",
)
== "2.1.0-SAC"
)
assert (
effective_reference_version(
recommended="",
fleet_latest="2.0.6-SAC",
min_version="2.1.0-SAC",
)
== "2.1.0-SAC"
)
assert (
effective_reference_version(
recommended="",
fleet_latest="2.0.35-SAC",
min_version="",
)
== "2.0.35-SAC"
)
def test_host_version_outdated_ssh_vs_git():
assert (
host_version_outdated(
"2.0.6-SAC",
recommended="2.1.0-SAC",
fleet_latest="2.0.6-SAC",
)
is True
)
assert (
host_version_outdated(
"2.0.6-SAC",
recommended="",
fleet_latest="2.0.6-SAC",
)
is False
)
assert (
host_version_outdated(
"2.0.6-SAC",
recommended="",
fleet_latest="2.0.6-SAC",
min_version="2.1.0-SAC",
)
is True
)
def test_latest_agent_versions_by_product(db_session):
now = datetime.now(timezone.utc)
db_session.add_all(
[
Host(
hostname="linux-a",
os_family="linux",
product="ssh-monitor",
product_version="1.2.5-SAC",
last_seen_at=now,
),
Host(
hostname="linux-b",
os_family="linux",
product="ssh-monitor",
product_version="2.0.1-SAC",
last_seen_at=now,
),
Host(
hostname="win-a",
os_family="windows",
product="rdp-login-monitor",
product_version="2.0.18-SAC",
last_seen_at=now,
),
Host(
hostname="win-b",
os_family="windows",
product="rdp-login-monitor",
product_version="2.0.25-SAC",
last_seen_at=now,
),
]
)
db_session.commit()
latest = latest_agent_versions_by_product(db_session)
assert latest["ssh-monitor"] == "2.0.1-SAC"
assert latest["rdp-login-monitor"] == "2.0.25-SAC"
+26
View File
@@ -0,0 +1,26 @@
"""Tests for client IP resolution behind reverse proxy."""
from types import SimpleNamespace
from app.services.client_ip import client_ip_from_request
def _request(*, client_host: str = "10.0.0.5", xff: str | None = None):
headers = {}
if xff is not None:
headers["x-forwarded-for"] = xff
return SimpleNamespace(client=SimpleNamespace(host=client_host), headers=headers)
def test_client_ip_without_forwarded_header():
assert client_ip_from_request(_request(client_host="203.0.113.10")) == "203.0.113.10"
def test_client_ip_uses_last_forwarded_hop():
req = _request(client_host="127.0.0.1", xff="203.0.113.99, 198.51.100.20")
assert client_ip_from_request(req) == "198.51.100.20"
def test_client_ip_ignores_spoofed_first_hop():
req = _request(client_host="127.0.0.1", xff="1.2.3.4, 203.0.113.50")
assert client_ip_from_request(req) == "203.0.113.50"
+258
View File
@@ -0,0 +1,258 @@
"""SAC daily report aggregation (F-NOT-05 / notif-32)."""
import uuid
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from sqlalchemy import select
from app.config import Settings
from app.models import Event, Host
from app.services import notify_dispatch
from app.services.daily_report import (
_aggregate_ssh,
generate_daily_report_for_host,
host_has_report_today,
run_daily_reports,
)
from app.services.notification_policy import NotificationPolicyConfig
from app.services.telegram_templates import format_event_telegram_html, sanitize_telegram_html
def _ssh_host(db) -> Host:
h = Host(
hostname="pilot-ssh",
os_family="linux",
product="ssh-monitor",
last_seen_at=datetime.now(timezone.utc),
)
db.add(h)
db.flush()
return h
def _cfg(**overrides) -> Settings:
base = {
"sac_daily_report_enabled": True,
"sac_daily_report_hour": 9,
"sac_daily_report_timezone": "UTC",
"sac_daily_report_skip_if_agent_sent": False,
"sac_daily_report_require_activity": False,
}
base.update(overrides)
return Settings(**base)
def test_aggregate_ssh_counts_types(db_session):
now = datetime.now(timezone.utc)
events = [
Event(
event_id=str(uuid.uuid4()),
host_id=1,
occurred_at=now,
category="auth",
type="ssh.login.success",
severity="info",
title="ok",
summary="",
payload={},
),
Event(
event_id=str(uuid.uuid4()),
host_id=1,
occurred_at=now,
category="auth",
type="ssh.login.failed",
severity="warning",
title="fail",
summary="",
details={"source_ip": "10.0.0.1"},
payload={},
),
Event(
event_id=str(uuid.uuid4()),
host_id=1,
occurred_at=now,
category="privilege",
type="privilege.sudo.command",
severity="warning",
title="sudo",
summary="",
payload={},
),
]
stats = _aggregate_ssh(events)
assert stats["successful_ssh"] == 1
assert stats["failed_ssh"] == 1
assert stats["sudo_commands"] == 1
assert "10.0.0.1" in stats["top_failed_ips"][0]
def test_skip_when_agent_report_today(db_session):
h = _ssh_host(db_session)
cfg = _cfg(sac_daily_report_skip_if_agent_sent=True)
now = datetime.now(timezone.utc)
db_session.add(
Event(
event_id=str(uuid.uuid4()),
host_id=h.id,
occurred_at=now,
received_at=now,
category="report",
type="report.daily.ssh",
severity="info",
title="agent",
summary="from agent",
details={"generated_by": "agent"},
payload={},
)
)
db_session.commit()
assert host_has_report_today(db_session, h.id, "report.daily.ssh", cfg) is True
res = generate_daily_report_for_host(db_session, h, cfg)
assert res is not None
assert res.created is False
assert res.skipped_reason == "agent_report_exists_today"
def test_generate_creates_report_and_notifies(db_session):
h = _ssh_host(db_session)
now = datetime.now(timezone.utc)
db_session.add(
Event(
event_id=str(uuid.uuid4()),
host_id=h.id,
occurred_at=now - timedelta(hours=1),
category="auth",
type="ssh.login.failed",
severity="warning",
title="fail",
summary="x",
details={"source_ip": "1.2.3.4"},
payload={},
)
)
db_session.commit()
cfg = _cfg()
with patch("app.services.daily_report.notify_daily_report") as mock_notify:
res = generate_daily_report_for_host(db_session, h, cfg)
assert res is not None
assert res.created is True
assert res.report_type == "report.daily.ssh"
mock_notify.assert_called_once()
ev = db_session.scalar(select(Event).where(Event.type == "report.daily.ssh"))
assert ev is not None
assert ev.details.get("generated_by") == "sac"
def test_generate_rdp_report_unified_format(db_session):
h = Host(
hostname="win-srv",
display_name="RDCB",
ipv4="10.0.0.5",
os_family="windows",
product="rdp-login-monitor",
last_seen_at=datetime.now(timezone.utc),
)
db_session.add(h)
db_session.flush()
now = datetime.now(timezone.utc)
db_session.add(
Event(
event_id=str(uuid.uuid4()),
host_id=h.id,
occurred_at=now,
category="auth",
type="rdp.login.success",
severity="info",
title="ok",
summary="",
details={"user": "admin"},
payload={},
)
)
db_session.commit()
cfg = _cfg()
with patch("app.services.daily_report.notify_daily_report"):
res = generate_daily_report_for_host(db_session, h, cfg)
assert res and res.created
ev = db_session.scalar(select(Event).where(Event.type == "report.daily.rdp"))
body = ev.details.get("report_body", "")
assert "ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS" in body
assert "RDCB (10.0.0.5)" in body
assert "АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ" in body
def test_run_daily_reports_respects_hour(db_session):
h = _ssh_host(db_session)
db_session.commit()
cfg = _cfg(sac_daily_report_hour=23)
early = datetime(2026, 5, 29, 8, 0, tzinfo=timezone.utc)
with patch("app.services.daily_report._now_in_tz", return_value=early):
out = run_daily_reports(db_session, cfg, force=False)
assert out == []
def test_run_daily_reports_force_bypasses_hour(db_session):
_ssh_host(db_session)
db_session.commit()
cfg = _cfg(sac_daily_report_hour=23)
early = datetime(2026, 5, 29, 8, 0, tzinfo=timezone.utc)
with patch("app.services.daily_report._now_in_tz", return_value=early):
with patch("app.services.daily_report.generate_daily_report_for_host") as mock_gen:
mock_gen.return_value = None
run_daily_reports(db_session, cfg, force=True)
mock_gen.assert_called()
def test_notify_daily_report_bypasses_min_severity():
event = Event(
event_id="00000000-0000-4000-8000-000000000601",
host_id=1,
occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc),
category="report",
type="report.daily.ssh",
severity="info",
title="report",
summary="s",
dedup_key="sac|1|report.daily.ssh|2026-05-29",
payload={},
)
policy = NotificationPolicyConfig(
min_severity="warning",
use_telegram=True,
use_webhook=False,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
with patch.object(notify_dispatch, "should_notify_event", return_value=True):
with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
notify_dispatch.notify_daily_report(event)
mock_tg.notify_event.assert_called_once()
def test_daily_report_telegram_html_uses_report_html(db_session):
h = Host(hostname="h1", display_name="H1", os_family="linux")
event = Event(
event_id=str(uuid.uuid4()),
host_id=1,
host=h,
occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc),
category="report",
type="report.daily.ssh",
severity="info",
title="Отчёт",
summary="short",
details={"report_html": "<b>📊 OK</b><br>line"},
payload={},
)
assert format_event_telegram_html(event) == (
"<b>📊 OK</b>\nline\n📡 Оповещение: SAC (Security Alert Center)"
)
def test_sanitize_telegram_html_strips_div_and_br():
raw = '<div class="agent-report">a<br>b</div>'
assert sanitize_telegram_html(raw) == "a\nb"
+192
View File
@@ -0,0 +1,192 @@
"""Unified daily report text format."""
from datetime import datetime, timezone
from app.models import Host
from app.services.daily_report_format import (
append_notification_source_plain,
build_report_body,
enrich_stats_for_storage,
format_notification_source_plain,
normalize_active_users_list,
normalize_report_body,
)
def test_build_report_body_ssh_matches_agent_layout():
host = Host(
hostname="haproxy",
display_name="HaProxy Kalina",
ipv4="192.168.160.117",
os_family="linux",
product="ssh-monitor",
)
when = datetime(2026, 5, 29, 9, 0, 5, tzinfo=timezone.utc)
stats = enrich_stats_for_storage(
"ssh",
{
"successful_ssh": 0,
"failed_ssh": 0,
"sudo_commands": 5,
"active_bans": 0,
"top_failed_ips": [],
"active_users": [
"👤 papatramp | pts/0 | с 2026-05-27 12:28 | 🌐 192.168.160.3",
],
},
)
body = build_report_body("ssh", host, stats, when, sac_generated=True)
assert "ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА" in body
assert "Agent version sac" in body
assert "🖥️ Сервер: HaProxy Kalina (192.168.160.117)" in body
assert " 📈 СТАТИСТИКА" in body
assert "Команд через sudo: 5" in body
assert " 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (1)" in body
assert "papatramp" in body
assert "\n\n\n" not in body
def test_build_report_body_windows_layout():
host = Host(
hostname="WIN01",
display_name="K6A-DC3",
ipv4="10.0.0.10",
os_family="windows",
product="rdp-login-monitor",
)
when = datetime(2026, 5, 29, 9, 0, 5, tzinfo=timezone.utc)
stats = enrich_stats_for_storage(
"windows",
{
"rdp_success": 1,
"rdp_failed": 2,
"active_bans": 0,
"top_failed_ips": ["1.2.3.4 — 2"],
"active_users": ["👤 DOMAIN\\user1", "👤 user2"],
},
)
body = build_report_body("windows", host, stats, when, sac_generated=True)
assert "ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS" in body
assert "Agent version sac" in body
assert "K6A-DC3 (10.0.0.10)" in body
assert "Успешных RDP подключений: 1" in body
assert " 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (2)" in body
assert "user2" in body
lines = [ln for ln in body.split("\n") if ln.strip().startswith("👤")]
assert len(lines) == 2
def test_build_report_body_includes_notification_source():
host = Host(hostname="h1", display_name="H1", os_family="linux", product="ssh-monitor")
when = datetime(2026, 5, 29, 9, 0, 5, tzinfo=timezone.utc)
stats = enrich_stats_for_storage("ssh", {"successful_ssh": 0, "failed_ssh": 0, "sudo_commands": 0, "active_bans": 0})
sac_body = build_report_body("ssh", host, stats, when, sac_generated=True)
assert "📡 Оповещение: SAC (Security Alert Center)" in sac_body
agent_body = build_report_body(
"ssh",
host,
{**stats, "agent_version": "1.2.11-SAC"},
when,
sac_generated=False,
)
assert "📡 Оповещение: агент (ssh-monitor 1.2.11-SAC)" in agent_body
def test_append_notification_source_plain_dedupes():
line = format_notification_source_plain(generated_by="sac")
body = append_notification_source_plain(f"title\n\n{line}", generated_by="sac")
assert body.count("📡 Оповещение:") == 1
def test_normalize_active_users_list_splits_combined_line():
users = normalize_active_users_list(["👤 k.khodasevich 👤 papatramp"])
assert len(users) == 2
assert users[0].strip().startswith("👤 k.khodasevich")
assert users[1].strip().startswith("👤 papatramp")
def test_normalize_report_body_adds_server_and_collapses_blanks():
host = Host(
hostname="srv",
display_name="Unimus Kalina",
ipv4="192.168.160.17",
product_version="1.2.10-SAC",
)
raw = "\n".join(
[
"📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА",
"",
"",
"🕐 Время отчета: 30.05.2026 09:00:00",
"",
"",
" 📈 СТАТИСТИКА ЗА ПОСЛЕДНИЕ 24 ЧАСА:",
" ✅ Успешных SSH подключений: 0",
"",
" 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (1):",
" 👤 u1 👤 u2",
]
)
body = normalize_report_body(raw, host, "ssh")
assert "Agent version 1.2.10-SAC" in body
assert "🖥️ Сервер: Unimus Kalina (192.168.160.17)" in body
assert "\n\n\n" not in body
user_lines = [ln for ln in body.split("\n") if ln.strip().startswith("👤")]
assert len(user_lines) == 2
assert " 👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (2)" in body
def test_normalize_ssh_active_users_mismatch_count_and_empty_body():
host = Host(hostname="srv", display_name="SSH", ipv4="10.0.0.1", product="ssh-monitor")
raw = "\n".join(
[
"📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА",
"👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (2):",
" (нет данных)",
]
)
body = normalize_report_body(raw, host, "ssh")
assert "👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (0)" in body
assert "(нет данных)" in body
assert not any(ln.strip().startswith("👤") for ln in body.split("\n"))
def test_normalize_rdp_empty_active_users_placeholder():
host = Host(hostname="gw", display_name="K6A-DC3", ipv4="192.168.160.40", product="rdp-login-monitor")
raw = "\n".join(
[
"📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ МОНИТОРИНГА WINDOWS",
"👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (0):",
" (нет активных пользователей / RDP-сессий)",
]
)
body = normalize_report_body(raw, host, "windows")
assert "👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (0)" in body
assert "нет активных пользователей" in body
assert "👤 (нет активных" not in body
def test_normalize_daily_report_reconciles_stats_from_body():
from app.services.daily_report_format import normalize_daily_report_details
host = Host(hostname="srv", display_name="SSH", ipv4="10.0.0.2", product="ssh-monitor")
details = {
"report_body": "\n".join(
[
"📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА",
"👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (9):",
" (нет данных)",
]
),
"stats": {
"successful_ssh": 1,
"active_sessions": 9,
"active_users": [],
"generated_by": "agent",
},
}
out = normalize_daily_report_details(details, host, "report.daily.ssh")
assert out is not None
assert out["stats"]["active_sessions"] == 0
assert out["stats"]["active_users"] == []
assert "👥 АКТИВНЫЕ ПОЛЬЗОВАТЕЛИ (0)" in out["report_body"]
+122
View File
@@ -0,0 +1,122 @@
"""Dashboard summary API."""
import uuid
from datetime import datetime, timedelta, timezone
from app.models import Event, Host, Problem
def _host(db, name: str) -> Host:
h = Host(
hostname=name,
os_family="linux",
product="ssh-monitor",
last_seen_at=datetime.now(timezone.utc),
)
db.add(h)
db.flush()
return h
def _event(db, host: Host, etype: str, *, received_at: datetime | None = None) -> Event:
now = received_at or datetime.now(timezone.utc)
e = Event(
event_id=str(uuid.uuid4()),
host_id=host.id,
occurred_at=now,
received_at=now,
category="security",
type=etype,
severity="info",
title=f"t {etype}",
summary="s",
payload={},
)
db.add(e)
db.flush()
return e
def test_dashboard_summary_top_and_problems_24h(client, db_session, jwt_headers):
h1 = _host(db_session, "web-01")
h2 = _host(db_session, "db-01")
now = datetime.now(timezone.utc)
for _ in range(3):
_event(db_session, h1, "ssh.login.failed")
_event(db_session, h2, "agent.heartbeat")
_event(db_session, h2, "agent.heartbeat")
db_session.add(
Problem(
host_id=h1.id,
title="open now",
summary="s",
severity="high",
status="open",
rule_id="rule:test",
fingerprint="fp1",
event_count=1,
last_seen_at=now,
created_at=now - timedelta(hours=2),
updated_at=now - timedelta(hours=2),
)
)
db_session.add(
Problem(
host_id=h2.id,
title="resolved today",
summary="s",
severity="warning",
status="resolved",
rule_id="rule:test",
fingerprint="fp2",
event_count=1,
last_seen_at=now,
created_at=now - timedelta(hours=5),
updated_at=now - timedelta(hours=1),
)
)
db_session.commit()
r = client.get("/api/v1/dashboards/summary", headers=jwt_headers)
assert r.status_code == 200
body = r.json()
assert body["events_last_24h"] == 5
assert body["problems_open"] == 1
assert body["problems_opened_24h"] == 1
assert body["problems_resolved_24h"] == 1
assert body["top_hosts"][0]["hostname"] == "web-01"
assert body["top_hosts"][0]["count"] == 3
types = {row["type"]: row["count"] for row in body["top_event_types"]}
assert types["ssh.login.failed"] == 3
assert types["agent.heartbeat"] == 2
def test_dashboard_daily_reports_24h_includes_ssh_and_rdp(client, db_session, jwt_headers):
h_ssh = _host(db_session, "ssh-pilot")
h_rdp = _host(db_session, "rdp-pilot")
h_rdp.product = "rdp-login-monitor"
now = datetime.now(timezone.utc)
_event(db_session, h_ssh, "report.daily.ssh", received_at=now)
_event(db_session, h_rdp, "report.daily.rdp", received_at=now)
db_session.commit()
r = client.get("/api/v1/dashboards/summary", headers=jwt_headers)
assert r.status_code == 200
assert r.json()["daily_reports_24h"] == 2
def test_dashboard_recent_events_ordered_by_received_at(client, db_session, jwt_headers):
h = _host(db_session, "live-host")
now = datetime.now(timezone.utc)
older = _event(db_session, h, "agent.lifecycle", received_at=now - timedelta(minutes=5))
newer = _event(db_session, h, "agent.heartbeat", received_at=now)
db_session.commit()
r = client.get("/api/v1/dashboards/recent-events?limit=8", headers=jwt_headers)
assert r.status_code == 200
rows = r.json()
assert len(rows) >= 2
assert rows[0]["id"] == newer.id
assert rows[0]["type"] == "agent.heartbeat"
assert rows[1]["id"] == older.id
+71
View File
@@ -0,0 +1,71 @@
"""Tests for SAC email notification helpers."""
from datetime import datetime, timezone
from unittest.mock import patch
from app.models import Event
from app.services import email_notify
from app.services.notification_settings import EmailConfig
def test_notify_event_respects_min_severity():
sent: list[tuple[str, str]] = []
def fake_send(subject: str, body: str, **kwargs) -> None:
sent.append((subject, body))
event = Event(
event_id="00000000-0000-4000-8000-000000000301",
host_id=1,
occurred_at=datetime(2026, 5, 29, 14, 0, tzinfo=timezone.utc),
category="auth",
type="rdp.login.failed",
severity="warning",
title="failed",
summary="smtp test",
payload={},
)
cfg = EmailConfig(
enabled=True,
smtp_host="smtp.example.com",
smtp_port=587,
smtp_user="u",
smtp_password="p",
mail_from="sac@example.com",
mail_to="admin@example.com",
smtp_starttls=True,
smtp_ssl=False,
min_severity="high",
source="env",
)
with patch.object(email_notify, "get_effective_email_config", return_value=cfg):
with patch.object(email_notify, "send_email_message", side_effect=fake_send):
email_notify.notify_event(event)
assert sent == []
cfg_warn = EmailConfig(
enabled=True,
smtp_host="smtp.example.com",
smtp_port=587,
smtp_user="",
smtp_password="",
mail_from="sac@example.com",
mail_to="admin@example.com",
smtp_starttls=True,
smtp_ssl=False,
min_severity="warning",
source="env",
)
with patch.object(email_notify, "get_effective_email_config", return_value=cfg_warn):
with patch.object(email_notify, "send_email_message", side_effect=fake_send):
email_notify.notify_event(event)
assert len(sent) == 1
def test_parse_mail_recipients():
assert email_notify.parse_mail_recipients("a@x.com; b@x.com, c@x.com") == [
"a@x.com",
"b@x.com",
"c@x.com",
]
+67
View File
@@ -0,0 +1,67 @@
"""Actor user extraction for event list UI."""
import uuid
from app.services.event_actor_user import extract_event_actor_user
def test_ssh_login_user():
assert extract_event_actor_user(
"ssh.login.success",
{"user": "deploy", "source_ip": "10.0.0.1"},
) == "deploy"
def test_rdp_login_username_fallback():
assert extract_event_actor_user(
"rdp.login.failed",
{"username": "DOMAIN\\admin"},
) == "DOMAIN\\admin"
def test_sudo_user():
assert extract_event_actor_user(
"privilege.sudo.command",
{"user": "root", "command": "apt update"},
) == "root"
def test_shadow_shadower_user():
assert extract_event_actor_user(
"rdp.shadow.control.started",
{"shadower_user": "admin", "target_user": "user1"},
) == "admin"
def test_heartbeat_no_user():
assert extract_event_actor_user("agent.heartbeat", {"user": "ignored"}) is None
def test_auth_event_missing_user():
assert extract_event_actor_user("ssh.login.failed", {}) is None
def test_events_list_includes_actor_user(client, auth_headers, jwt_headers):
event_id = str(uuid.uuid4())
payload = {
"schema_version": "1.0",
"event_id": event_id,
"occurred_at": "2026-05-27T10:00:00+03:00",
"source": {"product": "ssh-monitor", "product_version": "1.2.3-SAC"},
"host": {"hostname": "actor-host", "os_family": "linux"},
"category": "auth",
"type": "ssh.login.success",
"severity": "info",
"title": "SSH ok",
"summary": "login",
"details": {"user": "alice", "source_ip": "192.0.2.1"},
}
assert client.post("/api/v1/events", json=payload, headers=auth_headers).status_code == 201
listed = client.get("/api/v1/events?type=ssh.login.success&hostname=actor", headers=jwt_headers)
assert listed.status_code == 200
items = listed.json()["items"]
assert any(i["actor_user"] == "alice" for i in items)
dash = client.get("/api/v1/dashboards/recent-events?limit=5", headers=jwt_headers)
assert any(i.get("actor_user") == "alice" for i in dash.json())
@@ -0,0 +1,53 @@
"""Tests for per-event-type severity overrides."""
import uuid
from app.models.event_severity_override import EventSeverityOverride
VALID_EVENT = {
"schema_version": "1.0",
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"occurred_at": "2026-05-27T10:00:00+03:00",
"source": {"product": "rdp-login-monitor", "product_version": "1.2.3-SAC"},
"host": {"hostname": "test-host", "os_family": "windows"},
"category": "auth",
"type": "rdp.login.success",
"severity": "info",
"title": "RDP login",
"summary": "pytest",
}
def test_severity_override_applied_on_ingest(client, auth_headers, jwt_headers, db_session):
db_session.add(EventSeverityOverride(event_type="rdp.login.success", severity="warning"))
db_session.commit()
event_id = str(uuid.uuid4())
payload = {**VALID_EVENT, "event_id": event_id}
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
assert r.status_code == 201
listed = client.get("/api/v1/events", headers=jwt_headers, params={"type": "rdp.login.success"})
row = next(i for i in listed.json()["items"] if i["event_id"] == event_id)
assert row["severity"] == "warning"
def test_severity_overrides_settings_api(client, jwt_headers, db_session):
db_session.add(EventSeverityOverride(event_type="ssh.login.failed", severity="high"))
db_session.commit()
r = client.get("/api/v1/settings/notifications/severity-overrides", headers=jwt_headers)
assert r.status_code == 200
items = {i["event_type"]: i for i in r.json()["items"]}
assert items["ssh.login.failed"]["override_severity"] == "high"
assert items["rdp.login.success"]["default_severity"] == "info"
put = client.put(
"/api/v1/settings/notifications/severity-overrides",
headers=jwt_headers,
json={"overrides": {"rdp.login.success": "warning", "ssh.login.failed": None}},
)
assert put.status_code == 200
updated = {i["event_type"]: i for i in put.json()["items"]}
assert updated["rdp.login.success"]["override_severity"] == "warning"
assert updated["ssh.login.failed"]["override_severity"] is None
+117
View File
@@ -0,0 +1,117 @@
"""Tests for hiding event types from SAC UI."""
import uuid
from app.models.event_type_visibility import EventTypeVisibility
VALID_EVENT = {
"schema_version": "1.0",
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"occurred_at": "2026-05-27T10:00:00+03:00",
"source": {"product": "rdp-login-monitor", "product_version": "1.2.3-SAC"},
"host": {"hostname": "test-host", "os_family": "windows"},
"category": "auth",
"type": "agent.heartbeat",
"severity": "info",
"title": "Heartbeat",
"summary": "pytest",
}
LOGIN_EVENT = {
**VALID_EVENT,
"type": "rdp.login.success",
"title": "RDP login",
"summary": "login pytest",
}
def _ingest(client, auth_headers, payload):
event_id = str(uuid.uuid4())
r = client.post("/api/v1/events", json={**payload, "event_id": event_id}, headers=auth_headers)
assert r.status_code == 201
return event_id
def test_hidden_event_type_excluded_from_list(client, auth_headers, jwt_headers, db_session):
db_session.add(EventTypeVisibility(event_type="agent.heartbeat", show_in_events=False))
db_session.commit()
hb_id = _ingest(client, auth_headers, VALID_EVENT)
login_id = _ingest(client, auth_headers, LOGIN_EVENT)
listed = client.get("/api/v1/events", headers=jwt_headers)
assert listed.status_code == 200
ids = {item["event_id"] for item in listed.json()["items"]}
assert hb_id not in ids
assert login_id in ids
def test_hidden_event_type_returns_404_on_detail(client, auth_headers, jwt_headers, db_session):
db_session.add(EventTypeVisibility(event_type="agent.heartbeat", show_in_events=False))
db_session.commit()
_ingest(client, auth_headers, VALID_EVENT)
listed = client.get("/api/v1/events", headers=jwt_headers, params={"type": "agent.heartbeat"})
assert listed.json()["total"] == 0
def test_hidden_event_type_visible_on_host_detail_list(client, auth_headers, jwt_headers, db_session):
from app.models import Host
host = Host(hostname="inv-pc", os_family="windows", product="rdp-login-monitor")
db_session.add(host)
db_session.add(EventTypeVisibility(event_type="agent.inventory", show_in_events=False))
db_session.commit()
payload = {
**VALID_EVENT,
"type": "agent.inventory",
"category": "agent",
"title": "Inventory",
"summary": "hw",
"host": {"hostname": "inv-pc", "os_family": "windows"},
"details": {"inventory": {"memory_gb": 16}},
}
_ingest(client, auth_headers, payload)
global_list = client.get("/api/v1/events", headers=jwt_headers, params={"type": "agent.inventory"})
assert global_list.json()["total"] == 0
host_list = client.get(
"/api/v1/events",
headers=jwt_headers,
params={"host_id": host.id, "include_hidden": "true"},
)
assert host_list.status_code == 200
assert host_list.json()["total"] == 1
event_db_id = host_list.json()["items"][0]["id"]
detail = client.get(f"/api/v1/events/{event_db_id}", headers=jwt_headers)
assert detail.status_code == 200
assert detail.json()["type"] == "agent.inventory"
def test_include_hidden_requires_host_id(client, jwt_headers):
r = client.get("/api/v1/events", headers=jwt_headers, params={"include_hidden": "true"})
assert r.status_code == 400
def test_visibility_settings_api(client, jwt_headers, db_session):
put = client.put(
"/api/v1/settings/notifications/severity-overrides",
headers=jwt_headers,
json={"visibility": {"agent.heartbeat": False}},
)
assert put.status_code == 200
items = {i["event_type"]: i for i in put.json()["items"]}
assert items["agent.heartbeat"]["show_in_events"] is False
assert items["rdp.login.success"]["show_in_events"] is True
reset = client.put(
"/api/v1/settings/notifications/severity-overrides",
headers=jwt_headers,
json={"visibility": {"agent.heartbeat": None}},
)
assert reset.status_code == 200
items = {i["event_type"]: i for i in reset.json()["items"]}
assert items["agent.heartbeat"]["show_in_events"] is True
+54
View File
@@ -0,0 +1,54 @@
"""Tests for GET /api/v1/events filters."""
import uuid
VALID_RDG = {
"schema_version": "1.0",
"occurred_at": "2026-05-27T10:00:00+03:00",
"source": {"product": "rdp-login-monitor", "product_version": "1.2.3-SAC"},
"host": {"hostname": "gw-filter-test", "os_family": "windows"},
"category": "auth",
"severity": "info",
"title": "RDG",
"summary": "pytest",
}
def _ingest(client, auth_headers, *, event_type: str) -> str:
event_id = str(uuid.uuid4())
payload = {**VALID_RDG, "event_id": event_id, "type": event_type}
assert client.post("/api/v1/events", json=payload, headers=auth_headers).status_code == 201
return event_id
def test_events_type_filter_prefix(client, auth_headers, jwt_headers):
success_id = _ingest(client, auth_headers, event_type="rdg.connection.success")
disconnected_id = _ingest(client, auth_headers, event_type="rdg.connection.disconnected")
other_id = _ingest(client, auth_headers, event_type="rdp.login.success")
listed = client.get(
"/api/v1/events",
headers=jwt_headers,
params={"type": "rdg.connection", "hostname": "gw-filter-test", "page_size": 50},
)
assert listed.status_code == 200
body = listed.json()
ids = {row["event_id"] for row in body["items"]}
assert success_id in ids
assert disconnected_id in ids
assert other_id not in ids
def test_events_type_filter_exact_still_works(client, auth_headers, jwt_headers):
success_id = _ingest(client, auth_headers, event_type="rdg.connection.success")
disconnected_id = _ingest(client, auth_headers, event_type="rdg.connection.disconnected")
listed = client.get(
"/api/v1/events",
headers=jwt_headers,
params={"type": "rdg.connection.success", "hostname": "gw-filter-test", "page_size": 50},
)
assert listed.status_code == 200
ids = {row["event_id"] for row in listed.json()["items"]}
assert success_id in ids
assert disconnected_id not in ids
+9
View File
@@ -0,0 +1,9 @@
"""Version and health contract smoke tests (no DB required)."""
from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
def test_version_constants():
assert APP_VERSION == "0.5.0"
assert APP_NAME == "Security Alert Center"
assert APP_VERSION_LABEL == "Security Alert Center v.0.5.0"
+11
View File
@@ -0,0 +1,11 @@
"""Extended /health payload for ops monitoring."""
def test_health_includes_stale_and_last_event(client, db_session, auth_headers):
r = client.get("/health")
assert r.status_code == 200
body = r.json()
assert body["database"] == "ok"
assert "hosts_stale" in body
assert "last_event_received_at" in body
assert body["service"] == "security-alert-center"
+132
View File
@@ -0,0 +1,132 @@
"""Host inventory ingest and API."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from app.models import Event, Host
from app.services.host_inventory import diff_hardware, hardware_snapshot, inventory_fingerprint
def test_hardware_diff_detects_memory_change():
old = {"memory_gb": 16.0, "processor": [{"name": "CPU A", "cores": 4, "logical_processors": 8}]}
new = {"memory_gb": 32.0, "processor": [{"name": "CPU A", "cores": 4, "logical_processors": 8}]}
changes = diff_hardware(old, new)
assert len(changes) == 1
assert changes[0]["field"] == "memory_gb"
def test_hardware_diff_ignores_first_snapshot():
new = {"memory_gb": 16.0}
assert diff_hardware(None, new) == []
def test_ingest_inventory_stores_host_and_info(client, auth_headers, db_session):
event_id = str(uuid.uuid4())
payload = {
"schema_version": "1.0",
"event_id": event_id,
"occurred_at": "2026-06-04T10:00:00+03:00",
"source": {"product": "rdp-login-monitor", "product_version": "2.0.21-SAC"},
"host": {"hostname": "inv-pc", "os_family": "windows"},
"category": "agent",
"type": "agent.inventory",
"severity": "info",
"title": "Host inventory",
"summary": "Inventory snapshot",
"details": {
"inventory": {
"schema_version": 1,
"computer_name": "INV-PC",
"memory_gb": 32.0,
"processor": [{"name": "Intel Xeon", "cores": 8, "logical_processors": 16}],
"windows": {"product_name": "Windows Server 2019", "version": "1809"},
}
},
}
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
assert r.status_code == 201
host = db_session.scalar(select(Host).where(Host.hostname == "inv-pc"))
assert host is not None
assert host.inventory is not None
assert host.inventory["memory_gb"] == 32.0
assert host.inventory_updated_at is not None
assert host.os_version == "Windows Server 2019 (1809)"
def test_ingest_inventory_hardware_change_warning(client, auth_headers, db_session):
base = {
"schema_version": "1.0",
"occurred_at": "2026-06-04T10:00:00+03:00",
"source": {"product": "rdp-login-monitor", "product_version": "2.0.21-SAC"},
"host": {"hostname": "inv-change", "os_family": "windows"},
"category": "agent",
"type": "agent.inventory",
"severity": "info",
"title": "Host inventory",
"summary": "Inventory snapshot",
}
inv_v1 = {
"inventory": {
"memory_gb": 16.0,
"processor": [{"name": "CPU A", "cores": 4, "logical_processors": 8}],
"disks": [{"friendly_name": "Disk0", "media_type": "SSD", "size_gb": 500.0}],
}
}
assert (
client.post(
"/api/v1/events",
json={**base, "event_id": str(uuid.uuid4()), "details": inv_v1},
headers=auth_headers,
).status_code
== 201
)
inv_v2 = {
"inventory": {
"memory_gb": 32.0,
"processor": [{"name": "CPU A", "cores": 4, "logical_processors": 8}],
"disks": [{"friendly_name": "Disk0", "media_type": "SSD", "size_gb": 500.0}],
}
}
r = client.post(
"/api/v1/events",
json={**base, "event_id": str(uuid.uuid4()), "details": inv_v2},
headers=auth_headers,
)
assert r.status_code == 201
ev = db_session.scalar(select(Event).order_by(Event.id.desc()))
assert ev is not None
assert ev.severity == "warning"
assert ev.details.get("hardware_changes")
def test_get_host_detail(client, db_session, jwt_headers):
h = Host(
hostname="detail-host",
display_name="Detail Host",
os_family="windows",
product="rdp-login-monitor",
product_version="2.0.21-SAC",
inventory={"memory_gb": 64.0},
inventory_updated_at=datetime.now(timezone.utc),
last_seen_at=datetime.now(timezone.utc),
)
db_session.add(h)
db_session.commit()
r = client.get(f"/api/v1/hosts/{h.id}", headers=jwt_headers)
assert r.status_code == 200
body = r.json()
assert body["hostname"] == "detail-host"
assert body["inventory"]["memory_gb"] == 64.0
assert body["last_inventory_at"] is not None
def test_inventory_fingerprint_stable():
inv = {"memory_gb": 16.0, "processor": [{"name": "X", "cores": 2, "logical_processors": 4}]}
assert inventory_fingerprint(inv) == inventory_fingerprint(inv)
assert hardware_snapshot(inv)["memory_gb"] == 16.0
+141
View File
@@ -0,0 +1,141 @@
"""Manual host add: validation, probe, deploy kick-off."""
from unittest.mock import patch
import pytest
from app.models import Host
from app.services.host_manual_add import (
ManualHostAddError,
prepare_manual_host_add,
validate_linux_target,
validate_windows_target,
)
from app.services.winrm_connect import WinRmTestResult
from tests.test_agent_update import wait_remote_job
def test_validate_windows_rejects_ip():
with pytest.raises(ManualHostAddError, match="не IP"):
validate_windows_target("10.10.36.9")
def test_validate_windows_accepts_hostname():
assert validate_windows_target("WORKSTATION-01") == "WORKSTATION-01"
assert validate_windows_target(r"B26\WORKSTATION-01") == "WORKSTATION-01"
def test_validate_linux_accepts_ip():
assert validate_linux_target("10.10.36.9") == "10.10.36.9"
def test_prepare_manual_host_windows(db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
with patch("app.services.host_manual_add.test_winrm_connection") as mock_win:
mock_win.return_value = WinRmTestResult(
ok=True,
message="WinRM OK, hostname=WORKSTATION-01",
target="WORKSTATION-01",
hostname="WORKSTATION-01",
)
host = prepare_manual_host_add(
db_session,
platform="windows",
target="WORKSTATION-01",
)
assert host.hostname == "WORKSTATION-01"
assert host.product == "rdp-login-monitor"
assert host.os_family == "windows"
def test_api_manual_add_windows(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
with patch("app.services.host_manual_add.test_winrm_connection") as mock_win:
mock_win.return_value = WinRmTestResult(
ok=True,
message="WinRM OK, hostname=NEW-PC",
target="NEW-PC",
hostname="NEW-PC",
)
with patch("app.services.agent_update.run_winrm_rdp_monitor_update") as mock_deploy:
from app.services.winrm_connect import WinRmCmdResult
mock_deploy.return_value = WinRmCmdResult(
ok=True,
message="deploy ok",
target="NEW-PC",
stdout="2.1.8-SAC",
)
response = client.post(
"/api/v1/hosts/manual-add",
json={"platform": "windows", "target": "NEW-PC"},
headers=jwt_headers,
)
assert response.status_code == 202
body = response.json()
assert body["status"] == "running"
host_id = body["host_id"]
host = db_session.get(Host, host_id)
assert host is not None
assert host.hostname == "NEW-PC"
job = wait_remote_job(client, host_id, jwt_headers)
assert job["ok"] is True
def test_api_manual_add_rejects_windows_ip(jwt_headers, client):
response = client.post(
"/api/v1/hosts/manual-add",
json={"platform": "windows", "target": "192.168.1.10"},
headers=jwt_headers,
)
assert response.status_code == 400
assert "не IP" in response.json()["detail"]
def test_api_manual_add_linux(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
with patch("app.services.host_manual_add.test_ssh_connection") as mock_ssh:
from app.services.ssh_connect import SshCommandResult
mock_ssh.return_value = SshCommandResult(
ok=True,
message="SSH OK, hostname=linux-srv",
target="10.10.36.9",
stdout="linux-srv\n",
)
with patch("app.services.agent_update.run_ssh_monitor_update") as mock_update:
mock_update.return_value = SshCommandResult(
ok=True,
message="updated",
target="10.10.36.9",
agent_version="2.1.5-SAC",
)
response = client.post(
"/api/v1/hosts/manual-add",
json={"platform": "linux", "target": "10.10.36.9"},
headers=jwt_headers,
)
assert response.status_code == 202
host_id = response.json()["host_id"]
host = db_session.get(Host, host_id)
assert host.hostname == "linux-srv"
assert host.ipv4 == "10.10.36.9"
job = wait_remote_job(client, host_id, jwt_headers, timeout=8.0)
assert job["ok"] is True
@@ -0,0 +1,64 @@
"""Per-host management credential override."""
from app.models.host import Host
from app.services.linux_admin_settings import get_effective_linux_admin_for_host
from app.services.win_admin_settings import get_effective_win_admin_for_host
from app.services.host_mgmt_credentials import get_host_mgmt_access_view, upsert_host_mgmt_credentials
def test_win_admin_for_host_prefers_override(db_session):
host = Host(
hostname="HOME-PC",
os_family="windows",
product="rdp-login-monitor",
mgmt_user=".\\Admin",
mgmt_password="local-secret",
)
db_session.add(host)
db_session.commit()
db_session.refresh(host)
cfg = get_effective_win_admin_for_host(db_session, host)
assert cfg.configured is True
assert cfg.source == "host"
assert cfg.user == ".\\Admin"
assert cfg.password == "local-secret"
def test_linux_admin_for_host_prefers_override(db_session):
host = Host(
hostname="homeserver",
os_family="linux",
product="ssh-monitor",
mgmt_user="deploy",
mgmt_password="ssh-pass",
)
db_session.add(host)
db_session.commit()
db_session.refresh(host)
cfg = get_effective_linux_admin_for_host(db_session, host)
assert cfg.source == "host"
assert cfg.user == "deploy"
def test_upsert_and_clear_host_access(db_session):
host = Host(hostname="box", os_family="windows", product="rdp-login-monitor")
db_session.add(host)
db_session.commit()
db_session.refresh(host)
view = upsert_host_mgmt_credentials(
db_session,
host,
user="HOME\\user",
password="secret123",
)
assert view.has_override is True
assert view.password_set is True
assert view.user == "HOME\\user"
cleared = upsert_host_mgmt_credentials(db_session, host, clear=True)
assert cleared.has_override is False
assert cleared.password_set is False
assert get_host_mgmt_access_view(db_session, host).user is None
+218
View File
@@ -0,0 +1,218 @@
"""Tests for host session list/parse helpers."""
from types import SimpleNamespace
from app.services.host_sessions import (
HostSessionRow,
_event_login_user,
event_session_terminated,
event_supports_session_terminate,
filter_logind_session_rows,
filter_windows_sessions_for_user,
mark_event_session_terminated,
parse_loginctl_sessions,
parse_loginctl_sessions_json,
parse_qwinsta_sessions,
terminate_session_for_event,
)
from app.services.linux_admin_settings import LinuxAdminConfig
from app.services.win_admin_settings import WinAdminConfig
from app.services.winrm_connect import WinRmCmdResult
def test_parse_loginctl_sessions():
stdout = "c1 1000 alice seat0 pts/0 active -\n 2 1001 bob - tty2 active -\n"
rows = parse_loginctl_sessions(stdout)
assert len(rows) == 2
assert rows[0].session_id == "c1"
assert rows[0].user == "alice"
assert rows[0].tty == "pts/0"
def test_parse_loginctl_sessions_prefers_pts_over_ephemeral_duplicate():
stdout = """21166 1000 papatramp - pts/0 active no -
21169 1000 papatramp - - active no -
"""
rows = parse_loginctl_sessions(stdout)
assert len(rows) == 1
assert rows[0].session_id == "21166"
assert rows[0].tty == "pts/0"
def test_parse_loginctl_sessions_json():
stdout = """[
{"session":"21166","uid":1000,"user":"papatramp","seat":null,"tty":"pts/0","state":"active","idle":false,"since":null},
{"session":"21169","uid":1000,"user":"papatramp","seat":null,"tty":null,"state":"active","idle":false,"since":null}
]"""
rows = parse_loginctl_sessions_json(stdout)
assert len(rows) == 1
assert rows[0].session_id == "21166"
assert rows[0].tty == "pts/0"
def test_filter_logind_session_rows_keeps_distinct_users():
from app.services.host_sessions import HostSessionRow
rows = filter_logind_session_rows(
[
HostSessionRow(session_id="1", user="alice", tty="pts/0", state="active"),
HostSessionRow(session_id="2", user="bob", tty=None, state="active"),
]
)
assert len(rows) == 2
def test_parse_qwinsta_sessions_filters_user():
stdout = """SESSIONNAME USERNAME ID STATE
console Administrator 1 Active
rdp-tcp#0 B26\\alice 2 Active
"""
all_rows = parse_qwinsta_sessions(stdout)
assert len(all_rows) == 2
filtered = parse_qwinsta_sessions(stdout, filter_user="alice")
assert len(filtered) == 1
assert filtered[0].session_id == "2"
def test_parse_qwinsta_sessions_disconnected_without_sessionname():
"""Disc rows often omit SESSIONNAME; auto flap logoff relies on these."""
stdout = """SESSIONNAME USERNAME ID STATE
rdp-tcp#0 B26\\s.shelkovaya 2 Active
B26\\s.shelkovaya 5 Disc
rdp-tcp 65536 Listen
services 0 Disc
"""
rows = parse_qwinsta_sessions(stdout)
assert {(r.session_id, r.state.split()[0], r.session_name) for r in rows} == {
("2", "Active", "rdp-tcp#0"),
("5", "Disc", ""),
}
filtered = parse_qwinsta_sessions(stdout, filter_user=r"B26\s.shelkovaya")
assert [r.session_id for r in filtered] == ["2", "5"]
def test_parse_qwinsta_sessions_filters_domain_user():
stdout = """SESSIONNAME USERNAME ID STATE
rdp-tcp#1 B26\\bob 3 Active
"""
rows = parse_qwinsta_sessions(stdout, filter_user=r"B26\bob")
assert len(rows) == 1
assert rows[0].session_id == "3"
def test_event_supports_session_terminate_types():
class HostStub:
os_family = "linux"
product = "ssh-monitor"
class EventStub:
def __init__(self, event_type: str):
self.type = event_type
self.host = HostStub()
assert event_supports_session_terminate(EventStub("ssh.login.success")) is True
def test_event_login_user_without_orm_actor_user_attr():
event = SimpleNamespace(
type="rdp.login.success",
details={"user": "papatramp"},
)
assert _event_login_user(event) == "papatramp"
def test_filter_windows_sessions_for_user_matches_domain():
rows = [
HostSessionRow(session_id="2", user="B26\\papatramp", state="Active"),
HostSessionRow(session_id="3", user="B26\\alice", state="Active"),
]
matched = filter_windows_sessions_for_user(rows, "papatramp")
assert len(matched) == 1
assert matched[0].session_id == "2"
def test_terminate_session_for_event_windows_already_logged_off(monkeypatch):
host = SimpleNamespace(
os_family="windows",
product="rdp-login-monitor",
hostname="BIV-PC",
ipv4="192.168.165.39",
display_name=None,
inventory={},
)
event = SimpleNamespace(
type="rdp.login.success",
details={"user": "papatramp"},
host=host,
)
def fake_list(_host, _cfg):
return [], WinRmCmdResult(ok=True, message="ok", target="BIV-PC", stdout="SESSIONNAME USERNAME ID STATE")
monkeypatch.setattr(
"app.services.host_sessions.list_windows_sessions",
fake_list,
)
result = terminate_session_for_event(
event,
linux_cfg=LinuxAdminConfig(user="", password="", source="test"),
win_cfg=WinAdminConfig(user="B26\\admin", password="x", source="test"),
)
assert result.ok is True
assert "уже вышел" in result.message
def test_terminate_session_for_event_windows_no_actor_user_attr(monkeypatch):
host = SimpleNamespace(
os_family="windows",
product="rdp-login-monitor",
hostname="srv01",
ipv4="10.0.0.1",
display_name=None,
inventory={},
)
event = SimpleNamespace(
type="rdp.login.success",
details={"user": "papatramp"},
host=host,
)
rows = [HostSessionRow(session_id="2", user="B26\\papatramp", state="Active")]
def fake_list(_host, _cfg):
return rows, WinRmCmdResult(ok=True, message="ok", target="srv01")
def fake_term(_host, _cfg, sid):
assert sid == "2"
return WinRmCmdResult(ok=True, message="logged off", target="srv01")
monkeypatch.setattr(
"app.services.host_sessions.list_windows_sessions",
fake_list,
)
monkeypatch.setattr(
"app.services.host_sessions.terminate_windows_session",
fake_term,
)
result = terminate_session_for_event(
event,
linux_cfg=LinuxAdminConfig(user="", password="", source="test"),
win_cfg=WinAdminConfig(user="B26\\admin", password="x", source="test"),
)
assert result.ok is True
def test_event_session_terminated_flag():
event = SimpleNamespace(
type="rdp.login.success",
details={"session_terminated_at": "2026-06-24T10:00:00+00:00"},
)
assert event_session_terminated(event) is True
fresh = SimpleNamespace(type="rdp.login.success", details={"user": "alice"})
assert event_session_terminated(fresh) is False
mark_event_session_terminated(fresh, by_username="admin")
assert event_session_terminated(fresh) is True
assert fresh.details["session_terminated_by"] == "admin"
+355
View File
@@ -0,0 +1,355 @@
"""Proactive host_silence scan (stale heartbeat without waiting for ingest)."""
import uuid
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
import pytest
from sqlalchemy import select
from app.config import get_settings
from app.models import Problem
from app.services.host_silence_scan import run_host_silence_scan
from app.services.problem_rules import RULE_HOST_SILENCE
from tests.test_problem_rules import _ingest
@pytest.fixture
def scan_settings(monkeypatch):
monkeypatch.setenv("SAC_HEARTBEAT_STALE_MINUTES", "60")
monkeypatch.setenv("SAC_HOST_SILENCE_SCAN_ENABLED", "true")
monkeypatch.setenv("SAC_HOST_SILENCE_SCAN_INTERVAL_MINUTES", "5")
get_settings.cache_clear()
yield
get_settings.cache_clear()
def test_scan_creates_problem_for_stale_host(db_session, scan_settings):
hb = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2)
db_session.flush()
now = datetime.now(timezone.utc)
results = run_host_silence_scan(db_session, now=now)
assert len(results) == 1
assert results[0].created is True
assert results[0].problem.rule_id == RULE_HOST_SILENCE
problem = db_session.scalar(
select(Problem).where(
Problem.host_id == hb.host_id,
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status == "open",
)
)
assert problem is not None
def test_scan_does_not_duplicate_open_problem(db_session, scan_settings):
hb = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2)
db_session.flush()
now = datetime.now(timezone.utc)
first = run_host_silence_scan(db_session, now=now)
assert first[0].created is True
second = run_host_silence_scan(db_session, now=now + timedelta(minutes=5))
assert len(second) == 1
assert second[0].created is False
problems = db_session.scalars(
select(Problem).where(
Problem.host_id == hb.host_id,
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status == "open",
)
).all()
assert len(problems) == 1
def test_scan_does_not_duplicate_after_correlation_window(db_session, scan_settings, monkeypatch):
"""host_silence — одна open-проблема на хост, даже если прошло > correlation window."""
monkeypatch.setenv("SAC_PROBLEM_CORRELATION_WINDOW_MINUTES", "60")
get_settings.cache_clear()
hb = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2)
db_session.flush()
t0 = datetime.now(timezone.utc)
first = run_host_silence_scan(db_session, now=t0)
assert first[0].created is True
first[0].problem.last_seen_at = t0 - timedelta(hours=2)
db_session.flush()
later = run_host_silence_scan(db_session, now=t0 + timedelta(hours=3))
assert len(later) == 1
assert later[0].created is False
assert later[0].problem.id == first[0].problem.id
problems = db_session.scalars(
select(Problem).where(
Problem.host_id == hb.host_id,
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status == "open",
)
).all()
assert len(problems) == 1
def test_scan_skips_host_without_heartbeat(db_session, scan_settings):
_ingest(
db_session,
event_id=str(uuid.uuid4()),
type="ssh.login.success",
severity="info",
title="login",
summary="ok",
)
results = run_host_silence_scan(db_session)
assert results == []
def test_scan_suppresses_after_manual_resolve_within_cooldown(db_session, scan_settings, monkeypatch):
monkeypatch.setenv("SAC_HOST_SILENCE_MANUAL_RESOLVE_COOLDOWN_HOURS", "12")
get_settings.cache_clear()
hb = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2)
db_session.flush()
now = datetime.now(timezone.utc)
first = run_host_silence_scan(db_session, now=now)
assert first[0].created is True
problem = first[0].problem
problem.status = "resolved"
problem.resolved_by = "manual"
problem.updated_at = now
db_session.flush()
second = run_host_silence_scan(db_session, now=now + timedelta(minutes=10))
assert second == []
open_count = db_session.scalar(
select(Problem).where(
Problem.host_id == hb.host_id,
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status == "open",
)
)
assert open_count is None
resolved_count = len(
db_session.scalars(
select(Problem).where(
Problem.host_id == hb.host_id,
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status == "resolved",
)
).all()
)
assert resolved_count == 1
def test_scan_reopens_after_manual_cooldown_expires(db_session, scan_settings, monkeypatch):
monkeypatch.setenv("SAC_HOST_SILENCE_MANUAL_RESOLVE_COOLDOWN_HOURS", "12")
get_settings.cache_clear()
hb = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2)
db_session.flush()
now = datetime.now(timezone.utc)
first = run_host_silence_scan(db_session, now=now)
problem = first[0].problem
problem.status = "resolved"
problem.resolved_by = "manual"
problem.updated_at = now - timedelta(hours=13)
db_session.flush()
later = run_host_silence_scan(db_session, now=now)
assert len(later) == 1
assert later[0].created is True
assert later[0].problem.id == problem.id
assert later[0].problem.status == "open"
assert later[0].problem.resolved_by is None
def test_scan_does_not_duplicate_acknowledged_problem(db_session, scan_settings):
hb = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2)
db_session.flush()
now = datetime.now(timezone.utc)
first = run_host_silence_scan(db_session, now=now)
assert first[0].created is True
first[0].problem.status = "acknowledged"
db_session.flush()
second = run_host_silence_scan(db_session, now=now + timedelta(minutes=5))
assert len(second) == 1
assert second[0].created is False
assert second[0].problem.id == first[0].problem.id
problems = db_session.scalars(
select(Problem).where(
Problem.host_id == hb.host_id,
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status.in_(("open", "acknowledged")),
)
).all()
assert len(problems) == 1
def test_scan_dedupes_by_hostname_across_duplicate_hosts(db_session, scan_settings):
from app.models import Host
now = datetime.now(timezone.utc)
host_a = Host(
hostname="COMM-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.162.33",
)
host_b = Host(
hostname="COMM-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.162.33",
)
db_session.add_all([host_a, host_b])
db_session.flush()
hb = _ingest(
db_session,
host={"hostname": host_a.hostname, "os_family": "windows", "ipv4": host_a.ipv4},
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.host_id = host_a.id
hb.received_at = now - timedelta(hours=2)
db_session.flush()
first = run_host_silence_scan(db_session, now=now)
assert len(first) == 1
assert first[0].created is True
hb_b = _ingest(
db_session,
event_id=str(uuid.uuid4()),
host={"hostname": host_b.hostname, "os_family": "windows", "ipv4": host_b.ipv4},
source={"product": "rdp-login-monitor", "product_version": "2.0.0-SAC"},
type="agent.heartbeat",
category="agent",
severity="info",
title="hb2",
summary="heartbeat",
)
hb_b.host_id = host_b.id
hb_b.received_at = now - timedelta(hours=2)
db_session.flush()
second = run_host_silence_scan(db_session, now=now + timedelta(minutes=5))
assert len(second) == 2
assert all(not item.created for item in second)
assert {item.problem.id for item in second} == {first[0].problem.id}
active = db_session.scalars(
select(Problem).where(
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status.in_(("open", "acknowledged")),
)
).all()
assert len(active) == 1
def test_scan_skipped_when_advisory_lock_not_acquired(db_session, scan_settings, monkeypatch):
monkeypatch.setattr(
"app.services.host_silence_scan._host_silence_scan_lock",
lambda _db: False,
)
hb = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2)
db_session.flush()
assert run_host_silence_scan(db_session) == []
def test_scan_notifies_only_on_create(db_session, scan_settings):
hb = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2)
db_session.flush()
from app.services.host_silence_scan import dispatch_host_silence_notifications
with patch("app.services.notify_dispatch.notify_problem") as mock_notify:
results = run_host_silence_scan(db_session)
notified = dispatch_host_silence_notifications(db_session, results)
assert notified == 1
mock_notify.assert_called_once()
with patch("app.services.notify_dispatch.notify_problem") as mock_notify:
results2 = run_host_silence_scan(db_session)
notified2 = dispatch_host_silence_notifications(db_session, results2)
assert notified2 == 0
mock_notify.assert_not_called()
+183
View File
@@ -0,0 +1,183 @@
"""Hosts list API."""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from app.config import get_settings
from app.models import Event, Host, Problem
@pytest.fixture
def host_stale_settings(monkeypatch):
monkeypatch.setenv("SAC_HEARTBEAT_STALE_MINUTES", "60")
get_settings.cache_clear()
yield
get_settings.cache_clear()
def test_hosts_filter_agent_status_stale(client, db_session, jwt_headers, host_stale_settings):
now = datetime.now(timezone.utc)
stale_host = Host(
hostname="stale-pc",
os_family="windows",
product="rdp-login-monitor",
last_seen_at=now,
)
fresh_host = Host(
hostname="fresh-pc",
os_family="windows",
product="rdp-login-monitor",
last_seen_at=now,
)
unknown_host = Host(
hostname="unknown-pc",
os_family="linux",
product="ssh-monitor",
last_seen_at=now,
)
db_session.add_all([stale_host, fresh_host, unknown_host])
db_session.flush()
db_session.add(
Event(
event_id=str(uuid.uuid4()),
host_id=stale_host.id,
occurred_at=now - timedelta(hours=2),
received_at=now - timedelta(hours=2),
category="agent",
type="agent.heartbeat",
severity="info",
title="hb",
summary="s",
payload={},
)
)
db_session.add(
Event(
event_id=str(uuid.uuid4()),
host_id=fresh_host.id,
occurred_at=now - timedelta(minutes=5),
received_at=now - timedelta(minutes=5),
category="agent",
type="agent.heartbeat",
severity="info",
title="hb",
summary="s",
payload={},
)
)
db_session.commit()
r = client.get("/api/v1/hosts?agent_status=stale", headers=jwt_headers)
assert r.status_code == 200
body = r.json()
assert body["total"] == 1
assert body["items"][0]["hostname"] == "stale-pc"
assert body["items"][0]["agent_status"] == "stale"
r_all = client.get("/api/v1/hosts", headers=jwt_headers)
assert r_all.json()["total"] == 3
def test_hosts_search_by_display_name(client, db_session, jwt_headers):
h = Host(
hostname="pc-01",
display_name="UNMS Kalina",
os_family="linux",
product="ssh-monitor",
last_seen_at=datetime.now(timezone.utc),
)
db_session.add(h)
db_session.commit()
r = client.get("/api/v1/hosts?hostname=UNMS", headers=jwt_headers)
assert r.status_code == 200
body = r.json()
assert body["total"] == 1
assert body["items"][0]["display_name"] == "UNMS Kalina"
r2 = client.get("/api/v1/hosts?hostname=nomatch", headers=jwt_headers)
assert r2.json()["total"] == 0
def test_delete_host_removes_events_and_problems(client, db_session, jwt_headers):
h = Host(
hostname="test-host",
os_family="linux",
product="ssh-monitor",
last_seen_at=datetime.now(timezone.utc),
)
db_session.add(h)
db_session.flush()
ev = Event(
event_id=str(uuid.uuid4()),
host_id=h.id,
occurred_at=datetime.now(timezone.utc),
category="agent",
type="agent.test",
severity="info",
title="t",
summary="s",
payload={},
)
db_session.add(ev)
db_session.add(
Problem(
host_id=h.id,
title="p",
summary="s",
severity="warning",
status="open",
rule_id="rule:test",
fingerprint="fp-del-host",
event_count=1,
last_seen_at=datetime.now(timezone.utc),
)
)
db_session.commit()
host_id = h.id
r = client.delete(f"/api/v1/hosts/{host_id}", headers=jwt_headers)
assert r.status_code == 200
body = r.json()
assert body["hostname"] == "test-host"
assert body["deleted_events"] == 1
assert body["deleted_problems"] == 1
assert db_session.scalar(select(Host.id).where(Host.id == host_id)) is None
def test_delete_host_404(client, jwt_headers):
r = client.delete("/api/v1/hosts/999999", headers=jwt_headers)
assert r.status_code == 404
def test_delete_host_forbidden_for_monitor(client, db_session, jwt_monitor_headers):
h = Host(
hostname="monitor-blocked",
os_family="linux",
product="ssh-monitor",
last_seen_at=datetime.now(timezone.utc),
)
db_session.add(h)
db_session.commit()
r = client.delete(f"/api/v1/hosts/{h.id}", headers=jwt_monitor_headers)
assert r.status_code == 403
assert db_session.get(Host, h.id) is not None
def test_delete_host_requires_jwt(client, db_session):
h = Host(
hostname="no-auth",
os_family="linux",
product="ssh-monitor",
last_seen_at=datetime.now(timezone.utc),
)
db_session.add(h)
db_session.commit()
r = client.delete(f"/api/v1/hosts/{h.id}")
assert r.status_code == 401
+180
View File
@@ -0,0 +1,180 @@
"""Ingest idempotency and HTTP status contract."""
import uuid
from app.services.schema_validate import validate_event_payload
VALID_EVENT = {
"schema_version": "1.0",
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"occurred_at": "2026-05-27T10:00:00+03:00",
"source": {"product": "ssh-monitor", "product_version": "1.2.3-SAC"},
"host": {"hostname": "test-host", "os_family": "linux"},
"category": "agent",
"type": "agent.test",
"severity": "info",
"title": "Test",
"summary": "pytest ingest",
}
def test_schema_rejects_non_uuid_event_id():
bad = {**VALID_EVENT, "event_id": "not-a-uuid"}
errors = validate_event_payload(bad)
assert errors
def test_schema_rejects_missing_event_id():
payload = {k: v for k, v in VALID_EVENT.items() if k != "event_id"}
errors = validate_event_payload(payload)
assert errors
def test_ingest_created_201(client, auth_headers):
event_id = str(uuid.uuid4())
payload = {**VALID_EVENT, "event_id": event_id}
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
assert r.status_code == 201
data = r.json()
assert data["created"] is True
assert data["status"] == "created"
assert data["event_id"] == event_id
def test_ingest_duplicate_409(client, auth_headers):
event_id = str(uuid.uuid4())
payload = {**VALID_EVENT, "event_id": event_id}
assert client.post("/api/v1/events", json=payload, headers=auth_headers).status_code == 201
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
assert r.status_code == 409
data = r.json()
assert data["created"] is False
assert data["status"] == "duplicate"
assert data["event_id"] == event_id
def test_ingest_rdp_lifecycle_201(client, auth_headers):
event_id = str(uuid.uuid4())
payload = {
**VALID_EVENT,
"event_id": event_id,
"source": {"product": "rdp-login-monitor", "product_version": "1.2.11-SAC"},
"host": {"hostname": "K6A-DC3", "os_family": "windows"},
"type": "agent.lifecycle",
"title": "RDP login monitor started",
"summary": "Мониторинг запущен на K6A-DC3, версия 1.2.11-SAC",
"details": {"lifecycle": "started"},
}
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
assert r.status_code == 201
assert r.json()["event_id"] == event_id
def test_ingest_host_display_name_updated(client, auth_headers, db_session):
from sqlalchemy import select
from app.models import Host
event_id = str(uuid.uuid4())
payload = {
**VALID_EVENT,
"event_id": event_id,
"host": {
"hostname": "short-name",
"display_name": "UNMS Kalina",
"os_family": "linux",
},
}
assert client.post("/api/v1/events", json=payload, headers=auth_headers).status_code == 201
host = db_session.scalar(select(Host).where(Host.hostname == "short-name"))
assert host is not None
assert host.display_name == "UNMS Kalina"
payload2 = {
**payload,
"event_id": str(uuid.uuid4()),
"host": {"hostname": "short-name", "display_name": "Kalina UNMS", "os_family": "linux"},
}
assert client.post("/api/v1/events", json=payload2, headers=auth_headers).status_code == 201
db_session.refresh(host)
assert host.display_name == "Kalina UNMS"
def test_ingest_invalid_payload_422(client, auth_headers):
r = client.post(
"/api/v1/events",
json={"event_id": "bad", "summary": "x"},
headers=auth_headers,
)
assert r.status_code == 422
assert "schema_errors" in r.json()["detail"]
def test_ingest_daily_report_calls_notify_daily_report(client, auth_headers):
from unittest.mock import patch
from app.api.v1 import events as events_api
event_id = str(uuid.uuid4())
payload = {
**VALID_EVENT,
"event_id": event_id,
"category": "report",
"type": "report.daily.ssh",
"severity": "info",
"title": "Daily SSH report",
"summary": "stats",
"details": {"generated_by": "agent", "report_body": "line1"},
}
with patch.object(events_api, "schedule_notify_daily_report") as mock_daily:
with patch.object(events_api, "notify_event") as mock_event:
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
assert r.status_code == 201
mock_daily.assert_called_once()
mock_event.assert_not_called()
def test_ingest_lifecycle_defers_notify(client, auth_headers):
from unittest.mock import patch
from app.api.v1 import events as events_api
event_id = str(uuid.uuid4())
payload = {
**VALID_EVENT,
"event_id": event_id,
"type": "agent.lifecycle",
"title": "started",
"summary": "agent started",
"details": {"lifecycle": "started"},
}
with patch.object(events_api, "schedule_notify_lifecycle") as mock_defer:
with patch.object(events_api, "notify_lifecycle") as mock_sync:
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
assert r.status_code == 201
mock_defer.assert_called_once()
mock_sync.assert_not_called()
def test_ingest_auth_login_defers_notify(client, auth_headers):
from unittest.mock import patch
from app.api.v1 import events as events_api
event_id = str(uuid.uuid4())
payload = {
**VALID_EVENT,
"event_id": event_id,
"category": "auth",
"type": "ssh.login.success",
"severity": "info",
"title": "SSH login",
"summary": "user@host",
}
with patch.object(events_api, "schedule_notify_auth_login") as mock_defer:
with patch.object(events_api, "notify_auth_login") as mock_sync:
r = client.post("/api/v1/events", json=payload, headers=auth_headers)
assert r.status_code == 201
mock_defer.assert_called_once()
mock_sync.assert_not_called()
+23
View File
@@ -0,0 +1,23 @@
"""Ingest body size limit middleware."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.middleware.ingest_body_limit import IngestBodySizeLimitMiddleware
def test_ingest_body_size_limit_rejects_large_content_length():
app = FastAPI()
app.add_middleware(IngestBodySizeLimitMiddleware, max_bytes=128)
@app.post("/api/v1/events")
def ingest():
return {"ok": True}
client = TestClient(app)
response = client.post(
"/api/v1/events",
content=b"x" * 10,
headers={"content-length": "256"},
)
assert response.status_code == 413
+224
View File
@@ -0,0 +1,224 @@
"""Additional Linux admin API tests."""
import time
from unittest.mock import patch
def wait_remote_job(client, host_id, headers, timeout=5.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
response = client.get(
f"/api/v1/hosts/{host_id}/actions/remote-job",
headers=headers,
)
assert response.status_code == 200
body = response.json()
if not body.get("active") and body.get("status") != "running":
return body
time.sleep(0.05)
raise AssertionError("remote job did not finish in time")
def test_get_linux_admin_settings_env_default(jwt_headers, client, monkeypatch):
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "")
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "")
from app.config import get_settings
get_settings.cache_clear()
response = client.get("/api/v1/settings/linux-admin", headers=jwt_headers)
assert response.status_code == 200
body = response.json()
assert body["configured"] is False
assert body["source"] == "env"
def test_host_ssh_test_requires_admin(jwt_monitor_headers, client):
response = client.post("/api/v1/hosts/1/actions/ssh-test", headers=jwt_monitor_headers)
assert response.status_code == 403
def test_host_ssh_test_not_linux(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
from app.config import get_settings
from app.models import Host
get_settings.cache_clear()
host = Host(hostname="PC", os_family="windows", product="rdp-login-monitor", ipv4="10.0.0.1")
db_session.add(host)
db_session.commit()
db_session.refresh(host)
response = client.post(f"/api/v1/hosts/{host.id}/actions/ssh-test", headers=jwt_headers)
assert response.status_code == 400
assert "not Linux" in response.json()["detail"]
def test_host_agent_update_not_configured(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "")
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "")
from app.config import get_settings
from app.models import Host
get_settings.cache_clear()
host = Host(hostname="ubabuba", os_family="linux", product="ssh-monitor", ipv4="10.0.0.5")
db_session.add(host)
db_session.commit()
db_session.refresh(host)
response = client.post(f"/api/v1/hosts/{host.id}/actions/agent-update", headers=jwt_headers)
assert response.status_code == 400
assert "not configured" in response.json()["detail"].lower()
def test_put_linux_admin_settings_persists(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "")
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "")
from app.config import get_settings
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
get_settings.cache_clear()
response = client.put(
"/api/v1/settings/linux-admin",
headers=jwt_headers,
json={"user": "root", "password": "secret-pass"},
)
assert response.status_code == 200
body = response.json()
assert body["configured"] is True
assert body["user"] == "root"
assert body["source"] == "db"
assert body["password_hint"]
row = db_session.get(UiSettings, UI_SETTINGS_ROW_ID)
assert row is not None
assert row.linux_admin_user == "root"
assert row.linux_admin_password == "secret-pass"
def test_host_ssh_test_success(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
from app.config import get_settings
from app.models import Host
from app.services.ssh_connect import SshCommandResult
get_settings.cache_clear()
host = Host(hostname="ubabuba", os_family="linux", product="ssh-monitor", ipv4="10.0.0.5")
db_session.add(host)
db_session.commit()
db_session.refresh(host)
with patch("app.api.v1.hosts.test_ssh_connection") as mock_test:
mock_test.return_value = SshCommandResult(
ok=True,
message="SSH OK, hostname=ubabuba",
target="ubabuba",
stdout="ubabuba\n",
exit_code=0,
)
response = client.post(
f"/api/v1/hosts/{host.id}/actions/ssh-test",
headers=jwt_headers,
)
assert response.status_code == 200
body = response.json()
assert body["ok"] is True
assert "hostname=ubabuba" in body["message"]
assert body["target"] == "ubabuba"
assert body["ssh_admin_ok"] is True
db_session.refresh(host)
assert host.ssh_admin_ok is True
assert host.ssh_admin_checked_at is not None
def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
from app.config import get_settings
from app.models import Host
from app.services.ssh_connect import SshCommandResult
get_settings.cache_clear()
host = Host(hostname="ubabuba", os_family="linux", product="ssh-monitor", ipv4="10.0.0.5")
db_session.add(host)
db_session.commit()
db_session.refresh(host)
with patch("app.services.host_remote_actions.run_ssh_monitor_update") as mock_update:
mock_update.return_value = SshCommandResult(
ok=True,
message="SSH OK (ubabuba), exit 0\nSUMMARY updated",
target="ubabuba",
stdout="SUMMARY updated\n",
exit_code=0,
)
response = client.post(
f"/api/v1/hosts/{host.id}/actions/agent-update",
headers=jwt_headers,
)
assert response.status_code == 202
body = response.json()
assert body["status"] == "running"
job = wait_remote_job(client, host.id, jwt_headers)
assert job["ok"] is True
assert job["target"] == "ubabuba"
if "product_version" in job:
assert job["product_version"] is None or isinstance(job["product_version"], str)
def test_host_agent_update_job_shows_log_tail_and_completion_title(
jwt_headers, client, db_session, monkeypatch
):
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
from app.config import get_settings
from app.models import Host
from app.services.ssh_connect import SshCommandResult
from tests.test_agent_update import wait_remote_job
get_settings.cache_clear()
host = Host(hostname="router", os_family="linux", product="ssh-monitor", ipv4="10.0.0.1")
db_session.add(host)
db_session.commit()
db_session.refresh(host)
log_tail = (
"2026-07-08 14:18:15 INFO: === Script update completed successfully ===\n"
"2026-07-08 14:18:15 INFO: завершено успешно (код 0). Итог см. выше\n"
)
with (
patch("app.services.host_remote_actions.run_ssh_monitor_update") as mock_update,
patch("app.services.host_remote_actions.tail_ssh_monitor_update_log") as mock_tail,
):
mock_update.return_value = SshCommandResult(
ok=True,
message="SSH OK (router), exit 0",
target="router",
stdout="",
exit_code=0,
agent_version="2.3.2-SAC",
)
mock_tail.return_value = log_tail
response = client.post(
f"/api/v1/hosts/{host.id}/actions/agent-update",
headers=jwt_headers,
)
assert response.status_code == 202
job = wait_remote_job(client, host.id, jwt_headers)
assert job["ok"] is True
assert "готово" in (job.get("title") or "")
assert "2.3.2-SAC" in (job.get("message") or "")
assert "Script update completed successfully" in (job.get("output") or "")
mock_tail.assert_called()
+65
View File
@@ -0,0 +1,65 @@
"""SAC UI login rate limit and Telegram alert on brute-force."""
def _failed_login(client, username: str = "test-admin") -> int:
return client.post(
"/api/v1/auth/login",
json={"username": username, "password": "wrong-password"},
).status_code
def test_login_blocked_after_max_failures(client, monkeypatch):
monkeypatch.setenv("SAC_LOGIN_MAX_FAILURES", "3")
from app.config import get_settings
get_settings.cache_clear()
assert _failed_login(client) == 401
assert _failed_login(client) == 401
assert _failed_login(client) == 401
assert _failed_login(client) == 429
ok = client.post(
"/api/v1/auth/login",
json={"username": "test-admin", "password": "test-admin-password"},
)
assert ok.status_code == 429
get_settings.cache_clear()
def test_login_telegram_alert_on_threshold(client, monkeypatch):
monkeypatch.setenv("SAC_LOGIN_MAX_FAILURES", "3")
monkeypatch.setenv("SAC_LOGIN_ALERT_TELEGRAM", "true")
from app.config import get_settings
get_settings.cache_clear()
sent: list[str] = []
monkeypatch.setattr(
"app.services.login_rate_limit.send_telegram_text",
lambda text, **kwargs: sent.append(text),
)
for _ in range(3):
assert _failed_login(client, username="attacker") == 401
assert len(sent) == 1
assert "SAC: подбор пароля UI" in sent[0]
assert "attacker" in sent[0]
assert _failed_login(client, username="attacker") == 429
assert len(sent) == 1
get_settings.cache_clear()
def test_deactivated_user_token_rejected_on_me(client, jwt_monitor_headers, db_session):
from app.models.user import User
user = db_session.query(User).filter(User.username == "test-monitor").one()
user.is_active = False
db_session.commit()
response = client.get("/api/v1/auth/me", headers=jwt_monitor_headers)
assert response.status_code == 401
@@ -0,0 +1,88 @@
"""Login security whitelist and unblock."""
from datetime import datetime, timezone
from sqlalchemy import delete
from app.models.login_attempt import LoginAttempt
from app.services.login_security_settings import (
clear_web_login_block,
get_effective_login_security_config,
is_ip_login_whitelisted,
list_web_login_blocks,
parse_ip_whitelist_text,
upsert_login_security_settings,
)
def test_parse_ip_whitelist_text():
assert parse_ip_whitelist_text("192.168.160.3\n192.168.160.4") == [
"192.168.160.3",
"192.168.160.4",
]
assert parse_ip_whitelist_text("192.168.160.3, 10.0.0.1") == [
"192.168.160.3",
"10.0.0.1",
]
assert parse_ip_whitelist_text("not-an-ip\n192.168.1.1") == ["192.168.1.1"]
def test_whitelisted_ip_not_blocked(client, db_session, monkeypatch):
monkeypatch.setenv("SAC_LOGIN_MAX_FAILURES", "2")
monkeypatch.setenv("SAC_LOGIN_ALERT_TELEGRAM", "false")
monkeypatch.setattr(
"app.services.login_rate_limit.client_ip_from_request",
lambda _request: "192.168.160.3",
)
from app.config import get_settings
get_settings.cache_clear()
db_session.execute(delete(LoginAttempt))
db_session.commit()
upsert_login_security_settings(
db_session,
ip_whitelist_text="192.168.160.3",
sync_fail2ban=False,
)
for _ in range(5):
r = client.post(
"/api/v1/auth/login",
json={"username": "test-admin", "password": "wrong"},
)
assert r.status_code == 401
get_settings.cache_clear()
def test_list_web_blocks_and_clear(db_session, monkeypatch):
monkeypatch.setenv("SAC_LOGIN_MAX_FAILURES", "2")
monkeypatch.setenv("SAC_LOGIN_FAILURE_WINDOW_MINUTES", "15")
from app.config import get_settings
get_settings.cache_clear()
now = datetime.now(timezone.utc)
db_session.add(
LoginAttempt(ip_address="203.0.113.50", username="bad", success=False, created_at=now)
)
db_session.add(
LoginAttempt(ip_address="203.0.113.50", username="bad", success=False, created_at=now)
)
db_session.commit()
blocks = list_web_login_blocks(db_session)
assert any(b.ip_address == "203.0.113.50" for b in blocks)
deleted = clear_web_login_block(db_session, "203.0.113.50")
assert deleted >= 2
assert not list_web_login_blocks(db_session)
get_settings.cache_clear()
def test_is_ip_login_whitelisted():
wl = ("192.168.160.3",)
assert is_ip_login_whitelisted("192.168.160.3", wl)
assert not is_ip_login_whitelisted("192.168.160.4", wl)
+227
View File
@@ -0,0 +1,227 @@
"""Mobile / Seaca API."""
from datetime import datetime, timedelta, timezone
from app.models.mobile_device import MobileDevice
from app.models.mobile_enrollment_code import MobileEnrollmentCode
from app.models.mobile_settings import MOBILE_SETTINGS_ROW_ID, MobileSettings
from app.services.mobile_tokens import hash_mobile_secret
def _enable_mobile(db_session):
row = db_session.get(MobileSettings, MOBILE_SETTINGS_ROW_ID)
if row is None:
row = MobileSettings(id=MOBILE_SETTINGS_ROW_ID, devices_allowed=True, max_devices_per_user=3)
db_session.add(row)
else:
row.devices_allowed = True
db_session.commit()
def test_mobile_settings_admin(jwt_headers, client, db_session):
_enable_mobile(db_session)
response = client.get("/api/v1/mobile/settings", headers=jwt_headers)
assert response.status_code == 200
body = response.json()
assert body["devices_allowed"] is True
assert body["max_devices_per_user"] == 3
def test_create_enrollment_code_and_enroll(jwt_headers, client, db_session):
_enable_mobile(db_session)
create = client.post(
"/api/v1/mobile/enrollment-codes",
headers=jwt_headers,
json={"label": "test phone", "login_mode": "password", "expires_in_hours": 1},
)
assert create.status_code == 200
created = create.json()
assert created["enrollment_code"].startswith("sacmob_")
assert created["code_prefix"]
enroll = client.post(
"/api/v1/mobile/enroll",
json={
"enrollment_code": created["enrollment_code"],
"username": "test-monitor",
"password": "test-monitor-password",
"device_uuid": "uuid-test-device-001",
"display_name": "Pixel Test",
"fcm_token": "fcm-token-test",
},
)
assert enroll.status_code == 200
body = enroll.json()
assert body["username"] == "test-monitor"
assert body["role"] == "monitor"
assert body["device_id"] > 0
assert body["access_token"]
assert body["refresh_token"]
devices = client.get("/api/v1/mobile/devices", headers=jwt_headers)
assert devices.status_code == 200
assert len(devices.json()) == 1
assert devices.json()[0]["display_name"] == "Pixel Test"
def test_enroll_blocked_when_disabled(client, db_session, jwt_headers):
code_plain = "sacmob_testcode123456"
db_session.add(
MobileEnrollmentCode(
label="x",
code_hash=hash_mobile_secret(code_plain),
code_prefix=code_plain[:12],
login_mode="password",
max_uses=1,
use_count=0,
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
)
)
db_session.commit()
response = client.post(
"/api/v1/mobile/enroll",
json={
"enrollment_code": code_plain,
"username": "test-monitor",
"password": "test-monitor-password",
"device_uuid": "uuid-disabled-001",
},
)
assert response.status_code == 403
def test_refresh_token_rotation(client, db_session, jwt_headers):
_enable_mobile(db_session)
create = client.post(
"/api/v1/mobile/enrollment-codes",
headers=jwt_headers,
json={"login_mode": "password"},
)
code = create.json()["enrollment_code"]
enroll = client.post(
"/api/v1/mobile/enroll",
json={
"enrollment_code": code,
"username": "test-admin",
"password": "test-admin-password",
"device_uuid": "uuid-refresh-001",
},
)
data = enroll.json()
refresh = client.post(
"/api/v1/mobile/auth/refresh",
json={"refresh_token": data["refresh_token"], "device_uuid": "uuid-refresh-001"},
)
assert refresh.status_code == 200
refreshed = refresh.json()
assert refreshed["refresh_token"] != data["refresh_token"]
assert refreshed["device_id"] == data["device_id"]
def test_revoke_device_blocks_token(client, db_session, jwt_headers):
_enable_mobile(db_session)
create = client.post(
"/api/v1/mobile/enrollment-codes",
headers=jwt_headers,
json={"login_mode": "password"},
)
code = create.json()["enrollment_code"]
enroll = client.post(
"/api/v1/mobile/enroll",
json={
"enrollment_code": code,
"username": "test-monitor",
"password": "test-monitor-password",
"device_uuid": "uuid-revoke-001",
},
)
data = enroll.json()
headers = {"Authorization": f"Bearer {data['access_token']}"}
revoke = client.post(f"/api/v1/mobile/devices/{data['device_id']}/revoke", headers=jwt_headers)
assert revoke.status_code == 200
me = client.put(
"/api/v1/mobile/devices/me/fcm",
headers=headers,
json={"fcm_token": "new-token"},
)
assert me.status_code == 401
def test_reenroll_same_device_after_revoke(client, db_session, jwt_headers):
_enable_mobile(db_session)
device_uuid = "uuid-reenroll-001"
first_code = client.post(
"/api/v1/mobile/enrollment-codes",
headers=jwt_headers,
json={"login_mode": "password"},
).json()["enrollment_code"]
first = client.post(
"/api/v1/mobile/enroll",
json={
"enrollment_code": first_code,
"username": "test-monitor",
"password": "test-monitor-password",
"device_uuid": device_uuid,
"display_name": "Phone A",
},
)
assert first.status_code == 200
first_device_id = first.json()["device_id"]
revoke = client.post(f"/api/v1/mobile/devices/{first_device_id}/revoke", headers=jwt_headers)
assert revoke.status_code == 200
second_code = client.post(
"/api/v1/mobile/enrollment-codes",
headers=jwt_headers,
json={"login_mode": "password"},
).json()["enrollment_code"]
second = client.post(
"/api/v1/mobile/enroll",
json={
"enrollment_code": second_code,
"username": "test-monitor",
"password": "test-monitor-password",
"device_uuid": device_uuid,
"display_name": "Phone B",
},
)
assert second.status_code == 200
assert second.json()["device_id"] == first_device_id
devices = client.get("/api/v1/mobile/devices", headers=jwt_headers)
assert devices.status_code == 200
assert len(devices.json()) == 1
assert devices.json()[0]["display_name"] == "Phone B"
assert devices.json()[0]["is_active"] is True
def test_delete_device_record(client, db_session, jwt_headers):
_enable_mobile(db_session)
create = client.post(
"/api/v1/mobile/enrollment-codes",
headers=jwt_headers,
json={"login_mode": "password"},
)
code = create.json()["enrollment_code"]
enroll = client.post(
"/api/v1/mobile/enroll",
json={
"enrollment_code": code,
"username": "test-monitor",
"password": "test-monitor-password",
"device_uuid": "uuid-delete-001",
},
)
device_id = enroll.json()["device_id"]
deleted = client.delete(f"/api/v1/mobile/devices/{device_id}", headers=jwt_headers)
assert deleted.status_code == 204
devices = client.get("/api/v1/mobile/devices", headers=jwt_headers)
assert devices.status_code == 200
assert devices.json() == []
+99
View File
@@ -0,0 +1,99 @@
"""FCM push must not break event ingest."""
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import pytest
from app.models import Event, Host
from app.services.mobile_notify import MobileNotConfiguredError, _event_payload, _send_fcm_data
def test_event_payload_lifecycle_uses_telegram_wording():
host = Host(hostname="WIN01", display_name="K6A-DC3", os_family="windows")
event = Event(
event_id="00000000-0000-4000-8000-000000000511",
host_id=1,
host=host,
occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc),
category="agent",
type="agent.lifecycle",
severity="info",
title="started",
summary="short",
details={"lifecycle": "started", "trigger": "settings_reload", "telegram_via": "sac"},
payload={"source": {"product": "ssh-monitor", "product_version": "1.4.0-SAC"}},
)
title, body, data = _event_payload(event)
assert title == "✅ Агент запущен"
assert "graceful restart" in body
assert data["type"] == "agent.lifecycle"
def test_send_fcm_data_swallows_token_errors_by_default():
with patch(
"app.services.mobile_notify._fcm_access_token",
side_effect=MobileNotConfiguredError("FCM: install requests"),
):
with patch("app.services.mobile_notify.get_effective_fcm_config") as mock_cfg:
mock_cfg.return_value.enabled = True
mock_cfg.return_value.project_id = "proj"
mock_cfg.return_value.service_account_path = "/etc/fcm.json"
mock_cfg.return_value.configured = True
mock_cfg.return_value.active = True
_send_fcm_data(["token"], {"kind": "test"}, title="t", body="b")
def test_send_fcm_data_raises_token_errors_when_required():
with patch(
"app.services.mobile_notify._fcm_access_token",
side_effect=MobileNotConfiguredError("FCM: install requests"),
):
with patch("app.services.mobile_notify.get_effective_fcm_config") as mock_cfg:
mock_cfg.return_value.enabled = True
mock_cfg.return_value.project_id = "proj"
mock_cfg.return_value.service_account_path = "/etc/fcm.json"
mock_cfg.return_value.configured = True
mock_cfg.return_value.active = True
with pytest.raises(MobileNotConfiguredError):
_send_fcm_data(
["token"],
{"kind": "test"},
title="t",
body="b",
require_success=True,
)
def test_send_fcm_data_payload_is_data_only():
posted: list[dict] = []
def capture_post(_url, *, headers, json):
posted.append(json)
response = MagicMock()
response.raise_for_status.return_value = None
return response
with patch("app.services.mobile_notify._fcm_access_token", return_value="token"):
with patch("app.services.mobile_notify.get_effective_fcm_config") as mock_cfg:
mock_cfg.return_value.enabled = True
mock_cfg.return_value.project_id = "proj"
mock_cfg.return_value.service_account_path = "/etc/fcm.json"
mock_cfg.return_value.configured = True
mock_cfg.return_value.active = True
with patch("httpx.Client") as mock_client:
mock_client.return_value.__enter__.return_value.post = capture_post
_send_fcm_data(
["device-token"],
{"kind": "event", "id": "42", "severity": "high"},
title="Alert",
body="Summary text",
)
assert len(posted) == 1
message = posted[0]["message"]
assert "notification" not in message
assert message["data"]["title"] == "Alert"
assert message["data"]["body"] == "Summary text"
assert message["data"]["kind"] == "event"
assert message["data"]["id"] == "42"
@@ -0,0 +1,85 @@
"""Notification cooldown / dedup (notif-31)."""
from datetime import datetime, timedelta, timezone
from app.config import get_settings
from app.models import Event
from app.models.notification_cooldown import NotificationCooldown
from app.services.notification_cooldown import (
allow_notify,
build_event_cooldown_key,
build_problem_cooldown_key,
should_notify_event,
)
from app.models import Problem
def test_build_event_cooldown_key_prefers_dedup_key():
event = Event(
event_id="00000000-0000-4000-8000-000000000601",
host_id=1,
occurred_at=datetime(2026, 5, 29, 16, 0, tzinfo=timezone.utc),
category="auth",
type="rdp.login.failed",
severity="warning",
title="t",
summary="s",
dedup_key="rdp|host|failed|10.0.0.1|admin",
payload={},
)
assert build_event_cooldown_key(event) == "event:rdp|host|failed|10.0.0.1|admin"
def test_allow_notify_blocks_within_cooldown(db_session, monkeypatch):
monkeypatch.setenv("SAC_NOTIFY_COOLDOWN_ENABLED", "true")
monkeypatch.setenv("SAC_NOTIFY_EVENT_COOLDOWN_SEC", "90")
get_settings.cache_clear()
key = "event:test-key"
past = datetime.now(timezone.utc) - timedelta(seconds=30)
db_session.add(NotificationCooldown(cooldown_key=key, kind="event", last_notified_at=past))
db_session.commit()
assert allow_notify(db_session, key=key, kind="event") is False
def test_allow_notify_allows_after_cooldown(db_session, monkeypatch):
monkeypatch.setenv("SAC_NOTIFY_COOLDOWN_ENABLED", "true")
monkeypatch.setenv("SAC_NOTIFY_EVENT_COOLDOWN_SEC", "60")
get_settings.cache_clear()
key = "event:old"
past = datetime.now(timezone.utc) - timedelta(seconds=120)
db_session.add(NotificationCooldown(cooldown_key=key, kind="event", last_notified_at=past))
db_session.commit()
assert allow_notify(db_session, key=key, kind="event") is True
def test_should_notify_event_exempt_agent_test(db_session, monkeypatch):
monkeypatch.setenv("SAC_NOTIFY_EVENT_COOLDOWN_SEC", "90")
get_settings.cache_clear()
event = Event(
event_id="00000000-0000-4000-8000-000000000602",
host_id=1,
occurred_at=datetime(2026, 5, 29, 16, 0, tzinfo=timezone.utc),
category="agent",
type="agent.test",
severity="info",
title="t",
summary="s",
payload={},
)
assert should_notify_event(event, db_session) is True
def test_build_problem_cooldown_key():
problem = Problem(
title="brute",
summary="x",
severity="high",
status="open",
fingerprint="host:1|rdp.login.failed|rule:brute",
)
assert build_problem_cooldown_key(problem).startswith("problem:")
+65
View File
@@ -0,0 +1,65 @@
"""Global notification policy (notif-22)."""
from app.config import get_settings
from app.models.notification_policy import NotificationPolicy
from app.services.notification_policy import get_effective_notification_policy, upsert_notification_policy
from app.services.notification_severity import severity_meets_minimum
def test_severity_meets_minimum():
assert severity_meets_minimum("warning", "warning")
assert severity_meets_minimum("high", "warning")
assert not severity_meets_minimum("info", "warning")
def test_put_policy_persists_db(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("NOTIFY_MIN_SEVERITY", "high")
monkeypatch.setenv("NOTIFY_CHANNELS", "telegram")
get_settings.cache_clear()
response = client.put(
"/api/v1/settings/notifications/policy",
headers=jwt_headers,
json={
"min_severity": "warning",
"use_telegram": True,
"use_webhook": True,
"use_email": False,
"use_mobile": False,
},
)
assert response.status_code == 200
body = response.json()
assert body["min_severity"] == "warning"
assert body["use_webhook"] is True
assert body["source"] == "db"
row = db_session.get(NotificationPolicy, 1)
assert row is not None
assert row.use_webhook is True
cfg = get_effective_notification_policy(db_session)
assert cfg.min_severity == "warning"
def test_upsert_policy_syncs_channel_min_severity(db_session, monkeypatch):
monkeypatch.setenv("NOTIFY_MIN_SEVERITY", "high")
get_settings.cache_clear()
from app.models.notification_channel import NotificationChannel
db_session.add(
NotificationChannel(channel="telegram", enabled=False, min_severity="high")
)
db_session.commit()
upsert_notification_policy(
db_session,
min_severity="critical",
use_telegram=True,
use_webhook=False,
use_email=False,
use_mobile=False,
)
row = db_session.get(NotificationChannel, "telegram")
assert row.min_severity == "critical"
+366
View File
@@ -0,0 +1,366 @@
"""notify_dispatch respects global policy."""
from datetime import datetime, timezone
from unittest.mock import patch
from app.models import Event
from app.services import notify_dispatch
from app.services.notification_policy import NotificationPolicyConfig
def test_notify_event_calls_selected_channels():
event = Event(
event_id="00000000-0000-4000-8000-000000000401",
host_id=1,
occurred_at=datetime(2026, 5, 29, 15, 0, tzinfo=timezone.utc),
category="auth",
type="rdp.login.success",
severity="info",
title="ok",
summary="skip",
payload={},
)
policy = NotificationPolicyConfig(
min_severity="warning",
use_telegram=True,
use_webhook=False,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
notify_dispatch.notify_event(event)
mock_tg.notify_event.assert_not_called()
def test_notify_event_calls_selected_channels():
event = Event(
event_id="00000000-0000-4000-8000-000000000402",
host_id=1,
occurred_at=datetime(2026, 5, 29, 15, 0, tzinfo=timezone.utc),
category="auth",
type="rdp.login.failed",
severity="warning",
title="fail",
summary="send",
payload={},
)
policy = NotificationPolicyConfig(
min_severity="warning",
use_telegram=True,
use_webhook=True,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
with patch.object(notify_dispatch, "webhook_notify") as mock_wh:
with patch.object(notify_dispatch, "email_notify") as mock_em:
notify_dispatch.notify_event(event)
mock_tg.notify_event.assert_called_once()
mock_wh.notify_event.assert_called_once()
mock_em.notify_event.assert_not_called()
def test_notify_event_skips_agent_heartbeat():
event = Event(
event_id="00000000-0000-4000-8000-000000000404",
host_id=1,
occurred_at=datetime(2026, 5, 29, 15, 0, tzinfo=timezone.utc),
category="agent",
type="agent.heartbeat",
severity="info",
title="heartbeat",
summary="skip",
payload={},
)
policy = NotificationPolicyConfig(
min_severity="info",
use_telegram=True,
use_webhook=True,
use_email=True,
use_mobile=True,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
with patch.object(notify_dispatch, "webhook_notify") as mock_wh:
with patch.object(notify_dispatch, "email_notify") as mock_em:
with patch.object(notify_dispatch, "mobile_notify") as mock_mob:
notify_dispatch.notify_event(event)
mock_tg.notify_event.assert_not_called()
mock_wh.notify_event.assert_not_called()
mock_em.notify_event.assert_not_called()
mock_mob.notify_event.assert_not_called()
def test_notify_event_skipped_by_cooldown():
event = Event(
event_id="00000000-0000-4000-8000-000000000403",
host_id=1,
occurred_at=datetime(2026, 5, 29, 15, 0, tzinfo=timezone.utc),
category="auth",
type="rdp.login.failed",
severity="warning",
title="fail",
summary="send",
dedup_key="same-key",
payload={},
)
policy = NotificationPolicyConfig(
min_severity="warning",
use_telegram=True,
use_webhook=False,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
with patch.object(notify_dispatch, "should_notify_event", return_value=False):
with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
notify_dispatch.notify_event(event)
mock_tg.notify_event.assert_not_called()
def test_notify_event_skips_hidden_event_type(db_session):
from app.models.event_type_visibility import EventTypeVisibility
db_session.add(EventTypeVisibility(event_type="agent.inventory", show_in_events=False))
db_session.commit()
event = Event(
event_id="00000000-0000-4000-8000-000000000701",
host_id=1,
occurred_at=datetime(2026, 6, 22, 12, 0, tzinfo=timezone.utc),
category="agent",
type="agent.inventory",
severity="warning",
title="Hardware changed",
summary="memory",
payload={},
)
policy = NotificationPolicyConfig(
min_severity="warning",
use_telegram=True,
use_webhook=True,
use_email=True,
use_mobile=True,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
with patch.object(notify_dispatch, "should_notify_event", return_value=True):
with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
with patch.object(notify_dispatch, "webhook_notify") as mock_wh:
with patch.object(notify_dispatch, "email_notify") as mock_em:
with patch.object(notify_dispatch, "mobile_notify") as mock_mob:
notify_dispatch.notify_event(event, db=db_session)
mock_tg.notify_event.assert_not_called()
mock_wh.notify_event.assert_not_called()
mock_em.notify_event.assert_not_called()
mock_mob.notify_event.assert_not_called()
def test_notify_auth_login_bypasses_min_severity():
event = Event(
event_id="00000000-0000-4000-8000-000000000601",
host_id=1,
occurred_at=datetime(2026, 5, 31, 18, 36, tzinfo=timezone.utc),
category="auth",
type="rdp.login.success",
severity="info",
title="RDP login event 4624",
summary="papatramp from 192.168.160.3",
details={"telegram_via": "sac"},
payload={},
)
policy = NotificationPolicyConfig(
min_severity="warning",
use_telegram=True,
use_webhook=False,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
with patch.object(notify_dispatch, "should_notify_event", return_value=True):
with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
notify_dispatch.notify_auth_login(event)
mock_tg.notify_event.assert_called_once()
def test_notify_rdg_connection_bypasses_min_severity():
event = Event(
event_id="00000000-0000-4000-8000-000000000603",
host_id=1,
occurred_at=datetime(2026, 5, 31, 18, 36, tzinfo=timezone.utc),
category="auth",
type="rdg.connection.success",
severity="info",
title="RD Gateway event 302",
summary="RDG 302 papatramp -> 192.168.160.3",
details={"telegram_via": "sac"},
payload={},
)
policy = NotificationPolicyConfig(
min_severity="warning",
use_telegram=True,
use_webhook=False,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
with patch.object(notify_dispatch, "should_notify_event", return_value=True):
with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
notify_dispatch.notify_rdg_connection(event)
mock_tg.notify_event.assert_called_once()
def test_notify_auth_login_skips_telegram_when_via_agent():
event = Event(
event_id="00000000-0000-4000-8000-000000000602",
host_id=1,
occurred_at=datetime(2026, 5, 31, 18, 36, tzinfo=timezone.utc),
category="auth",
type="ssh.login.success",
severity="info",
title="SSH login",
summary="user from 10.0.0.1",
details={"telegram_via": "agent"},
payload={},
)
policy = NotificationPolicyConfig(
min_severity="warning",
use_telegram=True,
use_webhook=True,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
with patch.object(notify_dispatch, "should_notify_event", return_value=True):
with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
with patch.object(notify_dispatch, "webhook_notify") as mock_wh:
notify_dispatch.notify_auth_login(event)
mock_tg.notify_event.assert_not_called()
mock_wh.notify_event.assert_called_once()
def test_notify_lifecycle_bypasses_min_severity():
event = Event(
event_id="00000000-0000-4000-8000-000000000501",
host_id=1,
occurred_at=datetime(2026, 5, 29, 15, 0, tzinfo=timezone.utc),
category="agent",
type="agent.lifecycle",
severity="info",
title="started",
summary="agent started",
details={"lifecycle": "started", "telegram_via": "sac"},
payload={},
)
policy = NotificationPolicyConfig(
min_severity="warning",
use_telegram=True,
use_webhook=False,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
with patch.object(notify_dispatch, "should_notify_event", return_value=True):
with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
notify_dispatch.notify_lifecycle(event)
mock_tg.notify_event.assert_called_once()
def test_notify_lifecycle_skips_telegram_when_via_agent():
event = Event(
event_id="00000000-0000-4000-8000-000000000502",
host_id=1,
occurred_at=datetime(2026, 5, 29, 15, 0, tzinfo=timezone.utc),
category="agent",
type="agent.lifecycle",
severity="info",
title="started",
summary="agent started",
details={"lifecycle": "started", "telegram_via": "agent"},
payload={},
)
policy = NotificationPolicyConfig(
min_severity="warning",
use_telegram=True,
use_webhook=True,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
with patch.object(notify_dispatch, "should_notify_event", return_value=True):
with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
with patch.object(notify_dispatch, "webhook_notify") as mock_wh:
notify_dispatch.notify_lifecycle(event)
mock_tg.notify_event.assert_not_called()
mock_wh.notify_event.assert_called_once()
def test_notify_daily_report_skips_telegram_when_via_agent():
event = Event(
event_id="00000000-0000-4000-8000-000000000604",
host_id=1,
occurred_at=datetime(2026, 6, 3, 9, 0, tzinfo=timezone.utc),
category="report",
type="report.daily.rdp",
severity="info",
title="Ежедневный отчёт Windows",
summary="RDP 24ч: успех 0, неудач 0, банов 0",
details={"telegram_via": "agent", "report_body": "📊 body"},
payload={},
)
policy = NotificationPolicyConfig(
min_severity="warning",
use_telegram=True,
use_webhook=True,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
with patch.object(notify_dispatch, "should_notify_event", return_value=True):
with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
with patch.object(notify_dispatch, "webhook_notify") as mock_wh:
notify_dispatch.notify_daily_report(event)
mock_tg.notify_event.assert_not_called()
mock_wh.notify_event.assert_called_once()
def test_notify_daily_report_skips_telegram_when_via_agent():
event = Event(
event_id="00000000-0000-4000-8000-000000000604",
host_id=1,
occurred_at=datetime(2026, 6, 3, 9, 0, tzinfo=timezone.utc),
category="report",
type="report.daily.rdp",
severity="info",
title="Ежедневный отчёт Windows",
summary="RDP 24ч: успех 0, неудач 0, банов 0",
details={"telegram_via": "agent", "report_body": "📊 body"},
payload={},
)
policy = NotificationPolicyConfig(
min_severity="warning",
use_telegram=True,
use_webhook=True,
use_email=False,
use_mobile=False,
source="db",
)
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
with patch.object(notify_dispatch, "should_notify_event", return_value=True):
with patch.object(notify_dispatch, "telegram_notify") as mock_tg:
with patch.object(notify_dispatch, "webhook_notify") as mock_wh:
notify_dispatch.notify_daily_report(event)
mock_tg.notify_event.assert_not_called()
mock_wh.notify_event.assert_called_once()
+280
View File
@@ -0,0 +1,280 @@
"""Unit tests for problem rules v1."""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from app.config import get_settings
from app.models import Event, Problem
from app.services.ingest import ingest_event
from app.services.problem_rules import (
RULE_BRUTE_FORCE,
RULE_HOST_SILENCE,
RULE_PRIVILEGE_SPIKE,
evaluate_brute_force_burst,
evaluate_host_silence,
evaluate_privilege_spike,
last_heartbeat_at,
)
from app.services.problems import maybe_create_problem
from tests.test_ingest import VALID_EVENT
def _payload(**overrides):
now = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
base = {
**VALID_EVENT,
"event_id": str(uuid.uuid4()),
"occurred_at": now,
}
base.update(overrides)
return base
def _ingest(db, **overrides):
event, _ = ingest_event(db, _payload(**overrides))
db.flush()
return event
@pytest.fixture
def rule_settings(monkeypatch):
monkeypatch.setenv("SAC_BRUTE_FORCE_THRESHOLD", "3")
monkeypatch.setenv("SAC_BRUTE_FORCE_WINDOW_MINUTES", "15")
monkeypatch.setenv("SAC_PRIVILEGE_SPIKE_THRESHOLD", "3")
monkeypatch.setenv("SAC_PRIVILEGE_SPIKE_WINDOW_MINUTES", "10")
monkeypatch.setenv("SAC_HEARTBEAT_STALE_MINUTES", "60")
get_settings.cache_clear()
yield
get_settings.cache_clear()
def test_brute_force_burst_at_threshold(db_session, rule_settings):
for _ in range(3):
_ingest(
db_session,
type="ssh.login.failed",
severity="warning",
title="failed",
summary="fail",
details={"source_ip": "10.0.0.9"},
)
event = _ingest(
db_session,
type="ssh.login.failed",
severity="warning",
title="failed",
summary="fail",
details={"source_ip": "10.0.0.9"},
)
match = evaluate_brute_force_burst(db_session, event)
assert match is not None
assert match.rule_id == RULE_BRUTE_FORCE
problem, created = maybe_create_problem(db_session, event)
assert created is True
assert problem.rule_id == RULE_BRUTE_FORCE
def test_brute_force_below_threshold(db_session, rule_settings):
event = _ingest(
db_session,
type="ssh.login.failed",
severity="warning",
title="failed",
summary="fail",
details={"source_ip": "10.0.0.1"},
)
assert evaluate_brute_force_burst(db_session, event) is None
def test_privilege_spike_at_threshold(db_session, rule_settings):
for _ in range(2):
_ingest(
db_session,
type="privilege.sudo.command",
severity="warning",
title="sudo",
summary="sudo cmd",
)
event = _ingest(
db_session,
type="privilege.sudo.command",
severity="warning",
title="sudo",
summary="sudo cmd",
)
match = evaluate_privilege_spike(db_session, event)
assert match is not None
assert match.rule_id == RULE_PRIVILEGE_SPIKE
problem, created = maybe_create_problem(db_session, event)
assert created is True
assert problem.rule_id == RULE_PRIVILEGE_SPIKE
def test_host_silence_when_heartbeat_stale(db_session, rule_settings):
hb = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2)
db_session.flush()
assert last_heartbeat_at(db_session, hb.host_id) is not None
event = _ingest(
db_session,
type="ssh.login.success",
severity="info",
title="ok",
summary="login",
)
match = evaluate_host_silence(db_session, event)
assert match is not None
assert match.rule_id == RULE_HOST_SILENCE
problem, created = maybe_create_problem(db_session, event)
assert created is True
assert problem.rule_id == RULE_HOST_SILENCE
def test_heartbeat_resolves_host_silence(db_session, rule_settings):
hb_old = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb_old.received_at = datetime.now(timezone.utc) - timedelta(hours=2)
db_session.flush()
trigger = _ingest(
db_session,
type="ssh.login.success",
severity="info",
title="ok",
summary="login",
)
maybe_create_problem(db_session, trigger)
db_session.flush()
open_silence = db_session.scalar(
select(Problem).where(
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status == "open",
)
)
assert open_silence is not None
fresh_hb = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb fresh",
summary="heartbeat ok",
)
maybe_create_problem(db_session, fresh_hb)
db_session.refresh(open_silence)
assert open_silence.status == "resolved"
assert open_silence.resolved_by == "auto"
def test_host_silence_suppressed_after_manual_resolve_on_ingest(db_session, rule_settings, monkeypatch):
monkeypatch.setenv("SAC_HOST_SILENCE_MANUAL_RESOLVE_COOLDOWN_HOURS", "12")
get_settings.cache_clear()
hb = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2)
db_session.flush()
trigger = _ingest(
db_session,
type="ssh.login.success",
severity="info",
title="ok",
summary="login",
)
problem, created = maybe_create_problem(db_session, trigger)
assert created is True
problem.status = "resolved"
problem.resolved_by = "manual"
problem.updated_at = datetime.now(timezone.utc)
db_session.flush()
trigger2 = _ingest(
db_session,
type="ssh.login.success",
severity="info",
title="ok2",
summary="login2",
)
problem2, created2 = maybe_create_problem(db_session, trigger2)
assert problem2 is None
assert created2 is False
open_silence = db_session.scalar(
select(Problem).where(
Problem.rule_id == RULE_HOST_SILENCE,
Problem.status == "open",
)
)
assert open_silence is None
def test_host_silence_reopens_after_auto_resolve_when_still_stale(db_session, rule_settings, monkeypatch):
monkeypatch.setenv("SAC_HOST_SILENCE_MANUAL_RESOLVE_COOLDOWN_HOURS", "12")
get_settings.cache_clear()
hb = _ingest(
db_session,
type="agent.heartbeat",
category="agent",
severity="info",
title="hb",
summary="heartbeat",
)
hb.received_at = datetime.now(timezone.utc) - timedelta(hours=2)
db_session.flush()
trigger = _ingest(
db_session,
type="ssh.login.success",
severity="info",
title="ok",
summary="login",
)
problem, created = maybe_create_problem(db_session, trigger)
assert created is True
from app.services.problem_rules import resolve_host_silence_problems
resolve_host_silence_problems(db_session, hb.host_id)
db_session.refresh(problem)
assert problem.status == "resolved"
assert problem.resolved_by == "auto"
trigger2 = _ingest(
db_session,
type="ssh.login.success",
severity="info",
title="ok2",
summary="login2",
)
problem2, created2 = maybe_create_problem(db_session, trigger2)
assert created2 is True
assert problem2 is not None
assert problem2.status == "open"
assert problem2.id != problem.id
+200
View File
@@ -0,0 +1,200 @@
"""Problems correlation and API smoke tests."""
import uuid
from datetime import datetime, timedelta, timezone
from app.models import Host, Problem
from tests.test_ingest import VALID_EVENT
def _event_payload(**overrides):
now = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
base = {
**VALID_EVENT,
"event_id": str(uuid.uuid4()),
"occurred_at": now,
"severity": "high",
"type": "ssh.ip.banned",
"title": "Ban test",
"summary": "test problem correlation",
}
base.update(overrides)
return base
def test_problem_correlates_two_events(client, auth_headers, jwt_headers):
e1 = _event_payload()
e2 = _event_payload(title="Ban test 2")
assert client.post("/api/v1/events", json=e1, headers=auth_headers).status_code == 201
assert client.post("/api/v1/events", json=e2, headers=auth_headers).status_code == 201
r = client.get("/api/v1/problems?status=open", headers=jwt_headers)
assert r.status_code == 200
data = r.json()
assert data["total"] == 1
problem = data["items"][0]
assert problem["event_count"] == 2
assert problem["fingerprint"].startswith("h")
assert ":tssh.ip.banned:" in problem["fingerprint"]
detail = client.get(f"/api/v1/problems/{problem['id']}", headers=jwt_headers)
assert detail.status_code == 200
assert len(detail.json()["events"]) == 2
def test_problem_ack_and_resolve(client, auth_headers, jwt_headers):
payload = _event_payload()
client.post("/api/v1/events", json=payload, headers=auth_headers)
pid = client.get("/api/v1/problems?status=open", headers=jwt_headers).json()["items"][0]["id"]
ack = client.post(f"/api/v1/problems/{pid}/ack", headers=jwt_headers)
assert ack.status_code == 200
assert ack.json()["status"] == "acknowledged"
res = client.post(f"/api/v1/problems/{pid}/resolve", headers=jwt_headers)
assert res.status_code == 200
assert res.json()["status"] == "resolved"
def test_new_problem_after_resolve(client, auth_headers, jwt_headers):
p1 = _event_payload()
client.post("/api/v1/events", json=p1, headers=auth_headers)
pid = client.get("/api/v1/problems?status=open", headers=jwt_headers).json()["items"][0]["id"]
client.post(f"/api/v1/problems/{pid}/resolve", headers=jwt_headers)
p2 = _event_payload(summary="after resolve")
client.post("/api/v1/events", json=p2, headers=auth_headers)
open_items = client.get("/api/v1/problems?status=open", headers=jwt_headers).json()["items"]
assert len(open_items) == 1
assert open_items[0]["event_count"] == 1
assert open_items[0]["id"] != pid
def test_problems_created_within_hours(client, db_session, jwt_headers):
now = datetime.now(timezone.utc)
h = Host(
hostname="p-host",
os_family="linux",
product="ssh-monitor",
last_seen_at=now,
)
db_session.add(h)
db_session.flush()
db_session.add(
Problem(
host_id=h.id,
title="recent",
summary="s",
severity="warning",
status="open",
rule_id="rule:test",
fingerprint="fp-recent",
event_count=1,
last_seen_at=now,
created_at=now - timedelta(hours=2),
updated_at=now - timedelta(hours=2),
)
)
db_session.add(
Problem(
host_id=h.id,
title="old",
summary="s",
severity="warning",
status="resolved",
rule_id="rule:test",
fingerprint="fp-old",
event_count=1,
last_seen_at=now,
created_at=now - timedelta(days=3),
updated_at=now - timedelta(days=2),
)
)
db_session.commit()
r = client.get("/api/v1/problems?created_within_hours=24", headers=jwt_headers)
assert r.status_code == 200
body = r.json()
assert body["total"] == 1
assert body["items"][0]["title"] == "recent"
def test_problems_created_within_hours_excludes_resolved(client, db_session, jwt_headers):
now = datetime.now(timezone.utc)
h = Host(
hostname="p-host2",
os_family="linux",
product="ssh-monitor",
last_seen_at=now,
)
db_session.add(h)
db_session.flush()
db_session.add(
Problem(
host_id=h.id,
title="closed recently",
summary="s",
severity="warning",
status="resolved",
rule_id="rule:test",
fingerprint="fp-closed-recent",
event_count=1,
last_seen_at=now,
created_at=now - timedelta(hours=1),
updated_at=now - timedelta(minutes=30),
)
)
db_session.commit()
r = client.get("/api/v1/problems?created_within_hours=24", headers=jwt_headers)
assert r.status_code == 200
assert r.json()["total"] == 0
def test_problems_resolved_within_hours(client, db_session, jwt_headers):
now = datetime.now(timezone.utc)
h = Host(
hostname="r-host",
os_family="linux",
product="ssh-monitor",
last_seen_at=now,
)
db_session.add(h)
db_session.flush()
db_session.add(
Problem(
host_id=h.id,
title="closed today",
summary="s",
severity="warning",
status="resolved",
rule_id="rule:test",
fingerprint="fp-closed-today",
event_count=1,
last_seen_at=now,
created_at=now - timedelta(days=2),
updated_at=now - timedelta(hours=1),
)
)
db_session.add(
Problem(
host_id=h.id,
title="closed long ago",
summary="s",
severity="warning",
status="resolved",
rule_id="rule:test",
fingerprint="fp-closed-old",
event_count=1,
last_seen_at=now,
created_at=now - timedelta(days=10),
updated_at=now - timedelta(days=5),
)
)
db_session.commit()
r = client.get("/api/v1/problems?resolved_within_hours=24", headers=jwt_headers)
assert r.status_code == 200
body = r.json()
assert body["total"] == 1
assert body["items"][0]["title"] == "closed today"
+55
View File
@@ -0,0 +1,55 @@
"""Tests for RDG client workstation lookup."""
from datetime import datetime, timezone
from app.models import Host
from app.services.rdg_client_host import find_windows_host_by_ipv4, resolve_client_workstation
def test_find_windows_host_by_ipv4(db_session):
host = Host(
hostname="Andrisonova-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.160.113",
)
db_session.add(host)
db_session.commit()
found = find_windows_host_by_ipv4(db_session, "192.168.160.113")
assert found is not None
assert found.hostname == "Andrisonova-PC"
def test_resolve_client_workstation_from_event(db_session):
from app.models import Event
ws = Host(
hostname="Andrisonova-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.160.113",
)
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add_all([ws, gw])
db_session.commit()
event = Event(
event_id="ev-1",
host_id=gw.id,
occurred_at=datetime(2026, 6, 20, tzinfo=timezone.utc),
received_at=datetime(2026, 6, 20, tzinfo=timezone.utc),
category="auth",
type="rdg.connection.disconnected",
severity="info",
title="303",
summary="",
payload={},
details={"user": r"B26\user", "internal_ip": "192.168.160.113", "rdg_flap": True},
)
db_session.add(event)
db_session.commit()
client = resolve_client_workstation(db_session, event)
assert client.id == ws.id
assert client.hostname == "Andrisonova-PC"
+136
View File
@@ -0,0 +1,136 @@
"""Tests for RDG event display (access path, title/summary)."""
from datetime import datetime, timezone
import pytest
from app.models import Event, Host
from app.services.event_summary import event_to_summary
from app.services.rdg_display import (
ACCESS_PATH_DIRECT,
ACCESS_PATH_HAPROXY,
build_rdg_display,
classify_rdg_access_path,
event_supports_rdg_client_qwinsta,
)
def _rdg_event(
db_session,
*,
gw: Host,
event_type: str = "rdg.connection.success",
external_ip: str = "10.0.0.5",
internal_ip: str = "192.168.160.3",
win_id: int = 302,
) -> Event:
event = Event(
event_id=f"ev-rdg-{win_id}",
host_id=gw.id,
occurred_at=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc),
category="auth",
type=event_type,
severity="info",
title="RD Gateway event 302",
summary="",
payload={},
details={
"user": r"B26\papatramp",
"external_ip": external_ip,
"internal_ip": internal_ip,
"event_id_windows": win_id,
},
)
db_session.add(event)
db_session.commit()
db_session.refresh(event)
return event
def test_classify_rdg_access_path_direct(monkeypatch):
monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "192.168.160.50")
from app.config import get_settings
get_settings.cache_clear()
assert classify_rdg_access_path("10.0.0.5") == ACCESS_PATH_DIRECT
def test_classify_rdg_access_path_haproxy(monkeypatch):
monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "192.168.160.50,10.0.0.100")
from app.config import get_settings
get_settings.cache_clear()
assert classify_rdg_access_path("10.0.0.100") == ACCESS_PATH_HAPROXY
def test_build_rdg_display_title_and_path(db_session, monkeypatch):
monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "")
from app.config import get_settings
get_settings.cache_clear()
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
ws = Host(hostname="WS-PC", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.3")
db_session.add_all([gw, ws])
db_session.commit()
event = _rdg_event(db_session, gw=gw)
display = build_rdg_display(event, db_session)
assert display is not None
assert ACCESS_PATH_DIRECT in display.title
assert "WS-PC" in display.title
assert "192.168.160.3" in display.title
assert display.access_path == ACCESS_PATH_DIRECT
assert display.qwinsta_enabled is True
assert "papatramp" in display.summary
assert "шлюз K6A-DC3" in display.summary
assert "event 302" in display.title
def test_event_to_summary_enriches_rdg(db_session, monkeypatch):
monkeypatch.setenv("SAC_RDG_HAPROXY_EXTERNAL_IPS", "10.0.0.5")
from app.config import get_settings
get_settings.cache_clear()
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add(gw)
db_session.commit()
event = _rdg_event(db_session, gw=gw, external_ip="10.0.0.5")
summary = event_to_summary(event, db_session)
assert summary.rdg_access_path == ACCESS_PATH_HAPROXY
assert summary.rdg_qwinsta_enabled is True
assert "RDS подключение" in summary.title
assert ACCESS_PATH_HAPROXY in summary.title
def test_event_supports_rdg_client_qwinsta_without_flap(db_session):
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add(gw)
db_session.commit()
event = _rdg_event(db_session, gw=gw)
assert event_supports_rdg_client_qwinsta(event, db_session) is True
def test_event_supports_rdg_client_qwinsta_not_on_disconnect(db_session):
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add(gw)
db_session.commit()
event = _rdg_event(db_session, gw=gw, event_type="rdg.connection.disconnected", win_id=303)
assert event_supports_rdg_client_qwinsta(event, db_session) is False
@pytest.mark.parametrize(
"internal_ip",
["", None],
)
def test_event_supports_rdg_client_qwinsta_requires_internal_ip(db_session, internal_ip):
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add(gw)
db_session.commit()
event = _rdg_event(db_session, gw=gw, internal_ip=internal_ip or "")
if internal_ip is None:
event.details = {k: v for k, v in event.details.items() if k != "internal_ip"}
assert event_supports_rdg_client_qwinsta(event, db_session) is False
+339
View File
@@ -0,0 +1,339 @@
"""Tests for RDG 302→303 session flap rule."""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from app.config import get_settings
from app.models import Problem
from app.services.ingest import ingest_event
from app.services.problems import maybe_create_problem
from app.services.problem_rules import RULE_RDG_SESSION_FLAP
from app.services.rdg_session_flap import (
evaluate_rdg_session_flap,
event_has_rdg_flap,
find_rdg_success_before_end,
resolve_rdg_flap_summary,
resolve_rdg_qwinsta_enabled,
)
from app.services.event_summary import event_to_summary
from tests.test_ingest import VALID_EVENT
def _payload(**overrides):
base = {
**VALID_EVENT,
"event_id": str(uuid.uuid4()),
"occurred_at": datetime.now(timezone.utc).isoformat(),
}
base.update(overrides)
return base
def _ingest(db, occurred_at: datetime, **overrides):
payload = _payload(**overrides)
payload["occurred_at"] = occurred_at.isoformat()
event, _ = ingest_event(db, payload)
db.flush()
return event
@pytest.fixture
def rdg_settings(monkeypatch):
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MIN_SEC", "1")
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MAX_SEC", "10")
monkeypatch.setenv("SAC_RDG_FLAP_DEDUP_SEC", "30")
get_settings.cache_clear()
yield
get_settings.cache_clear()
def test_rdg_flap_detects_302_then_303(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
user = "B26\\test.user"
details = {"user": user, "internal_ip": "192.168.163.32"}
_ingest(
db_session,
t0,
type="rdg.connection.success",
category="auth",
severity="info",
title="RD Gateway event 302",
summary="302",
details=details,
)
end = _ingest(
db_session,
t0 + timedelta(seconds=4),
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="RD Gateway event 303",
summary="303",
details=details,
)
match = evaluate_rdg_session_flap(db_session, end)
assert match is not None
assert match.rule_id == RULE_RDG_SESSION_FLAP
assert event_has_rdg_flap(end) is True
problem, created = maybe_create_problem(db_session, end)
assert created is True
assert problem.rule_id == RULE_RDG_SESSION_FLAP
def test_rdg_flap_ignores_gap_over_10_sec(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
user = "B26\\slow.user"
details = {"user": user}
_ingest(
db_session,
t0,
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
end = _ingest(
db_session,
t0 + timedelta(seconds=15),
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
assert find_rdg_success_before_end(db_session, end) is None
assert evaluate_rdg_session_flap(db_session, end) is None
def test_rdg_flap_dedup_within_30_sec(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
user = "B26\\dup.user"
details = {"user": user}
def pair(at: datetime):
_ingest(
db_session,
at,
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
return _ingest(
db_session,
at + timedelta(seconds=5),
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
end1 = pair(t0)
maybe_create_problem(db_session, end1)
db_session.flush()
end2 = pair(t0 + timedelta(seconds=10))
maybe_create_problem(db_session, end2)
db_session.flush()
from sqlalchemy import func, select
open_count = db_session.scalar(
select(func.count()).select_from(Problem).where(
Problem.rule_id == RULE_RDG_SESSION_FLAP,
Problem.status == "open",
)
)
assert open_count == 1
def test_resolve_rdg_flap_summary_for_302_and_303(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
user = "B26\\pair.user"
details = {"user": user, "internal_ip": "192.168.163.48"}
start = _ingest(
db_session,
t0,
type="rdg.connection.success",
category="auth",
severity="warning",
title="RD Gateway event 302",
summary="302",
details=details,
)
end = _ingest(
db_session,
t0 + timedelta(seconds=5),
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="RD Gateway event 303",
summary="303",
details=details,
)
evaluate_rdg_session_flap(db_session, end)
db_session.flush()
end_flap, end_pair, end_qwinsta = resolve_rdg_flap_summary(db_session, end)
start_flap, start_pair, start_qwinsta = resolve_rdg_flap_summary(db_session, start)
assert end_flap is True
assert end_pair == start.id
assert end_qwinsta == start.id
assert start_flap is True
assert start_pair == end.id
assert start_qwinsta == start.id
end_summary = event_to_summary(end, db_session)
start_summary = event_to_summary(start, db_session)
assert end_summary.rdg_flap is True
assert start_summary.rdg_flap is True
assert start_summary.rdg_flap_qwinsta_event_id == start.id
assert start_summary.rdg_qwinsta_enabled is True
assert end_summary.rdg_qwinsta_enabled is False
def test_rdg_qwinsta_disabled_after_normal_session_end(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
user = "B26\\normal.user"
details = {"user": user, "internal_ip": "192.168.163.49"}
start = _ingest(
db_session,
t0,
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
end = _ingest(
db_session,
t0 + timedelta(minutes=20),
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
assert resolve_rdg_qwinsta_enabled(db_session, start) is False
assert resolve_rdg_qwinsta_enabled(db_session, end) is False
start_summary = event_to_summary(start, db_session)
end_summary = event_to_summary(end, db_session)
assert start_summary.rdg_qwinsta_enabled is False
assert end_summary.rdg_qwinsta_enabled is False
def test_rdg_qwinsta_enabled_while_session_open(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
details = {"user": r"B26\active.user", "internal_ip": "192.168.163.50"}
start = _ingest(
db_session,
t0,
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
assert resolve_rdg_qwinsta_enabled(db_session, start) is True
assert event_to_summary(start, db_session).rdg_qwinsta_enabled is True
def test_rdg_qwinsta_disabled_on_flap_302_after_later_success(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
user = r"B26\m.semenova"
details = {"user": user, "internal_ip": "192.168.164.45"}
flap_start = _ingest(
db_session,
t0,
type="rdg.connection.success",
category="auth",
severity="warning",
title="302",
summary="302",
details=details,
)
flap_end = _ingest(
db_session,
t0 + timedelta(seconds=5),
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
evaluate_rdg_session_flap(db_session, flap_end)
db_session.flush()
assert resolve_rdg_qwinsta_enabled(db_session, flap_start) is True
_ingest(
db_session,
t0 + timedelta(minutes=2, seconds=44),
type="rdg.connection.success",
category="auth",
severity="warning",
title="302",
summary="302",
details=details,
)
assert resolve_rdg_qwinsta_enabled(db_session, flap_start) is False
assert event_to_summary(flap_start, db_session).rdg_qwinsta_enabled is False
def test_rdg_qwinsta_stays_enabled_when_unrelated_303_without_internal_ip(db_session, rdg_settings):
t0 = datetime.now(timezone.utc)
user = "B26\\khodasevich"
details_302 = {"user": user, "internal_ip": "192.168.160.209"}
details_303_other = {"user": user}
start = _ingest(
db_session,
t0,
type="rdg.connection.success",
category="auth",
severity="warning",
title="302",
summary="302",
details=details_302,
)
_ingest(
db_session,
t0 + timedelta(minutes=5),
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details_303_other,
)
assert resolve_rdg_qwinsta_enabled(db_session, start) is True
assert event_to_summary(start, db_session).rdg_qwinsta_enabled is True
+223
View File
@@ -0,0 +1,223 @@
"""API tests for RDG qwinsta/logoff via WinRM on client workstation."""
from datetime import datetime, timezone
from unittest.mock import patch
from sqlalchemy import inspect as sa_inspect
from app.models import Event, Host
from app.services.winrm_connect import WinRmCmdResult
def _rdg_success_event(db_session, *, gw: Host, ws: Host) -> Event:
event = Event(
event_id="ev-rdg-302",
host_id=gw.id,
occurred_at=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc),
category="auth",
type="rdg.connection.success",
severity="info",
title="RD Gateway event 302",
summary="",
payload={},
details={
"user": r"B26\papatramp",
"internal_ip": ws.ipv4,
},
)
db_session.add(event)
db_session.commit()
db_session.refresh(event)
return event
def test_qwinsta_via_winrm_on_client_host(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
ws = Host(
hostname="Andrisonova-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.160.113",
)
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add_all([ws, gw])
db_session.commit()
ws_id = ws.id
event = _rdg_success_event(db_session, gw=gw, ws=ws)
qwinsta_out = " SESSIONNAME USERNAME ID STATE\r\n rdp-tcp#0 B26\\papatramp 2 Active\r\n"
with patch("app.services.rdg_winrm_actions.run_winrm_on_host_targets") as mock_run:
mock_run.return_value = (
WinRmCmdResult(
ok=True,
message="WinRM OK (Andrisonova-PC), exit 0",
target="Andrisonova-PC",
stdout=qwinsta_out,
exit_code=0,
),
["Andrisonova-PC=OK"],
)
response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers)
mock_run.assert_called_once()
called_identity = sa_inspect(mock_run.call_args.args[0]).identity
assert called_identity is not None
assert called_identity[0] == ws_id
assert response.status_code == 200
body = response.json()
assert body["status"] == "completed"
assert body["client_hostname"] == "Andrisonova-PC"
assert body["target"] == "Andrisonova-PC"
assert "papatramp" in (body["result_stdout"] or "")
def test_qwinsta_client_not_in_hosts(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add(gw)
db_session.commit()
event = Event(
event_id="ev-flap-2",
host_id=gw.id,
occurred_at=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc),
category="auth",
type="rdg.connection.success",
severity="info",
title="302",
summary="",
payload={},
details={"user": r"B26\user", "internal_ip": "192.168.160.999"},
)
db_session.add(event)
db_session.commit()
response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers)
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
def test_qwinsta_without_rdg_flap(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
ws = Host(
hostname="Andrisonova-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.160.113",
)
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add_all([ws, gw])
db_session.commit()
event = Event(
event_id="ev-rdg-plain",
host_id=gw.id,
occurred_at=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc),
category="auth",
type="rdg.connection.success",
severity="info",
title="302",
summary="",
payload={},
details={"user": r"B26\papatramp", "internal_ip": ws.ipv4},
)
db_session.add(event)
db_session.commit()
with patch("app.services.rdg_winrm_actions.run_winrm_on_host_targets") as mock_run:
mock_run.return_value = (
WinRmCmdResult(ok=True, message="OK", target="Andrisonova-PC", stdout="ok", exit_code=0),
["Andrisonova-PC=OK"],
)
response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers)
assert response.status_code == 200
mock_run.assert_called_once()
def test_qwinsta_rejects_rdg_without_internal_ip(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add(gw)
db_session.commit()
event = Event(
event_id="ev-rdg-no-ip",
host_id=gw.id,
occurred_at=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc),
category="auth",
type="rdg.connection.success",
severity="info",
title="302",
summary="",
payload={},
details={"user": r"B26\user"},
)
db_session.add(event)
db_session.commit()
response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers)
assert response.status_code == 400
assert "internal_ip" in response.json()["detail"].lower()
def test_qwinsta_rejects_rdg_303_disconnect(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
ws = Host(
hostname="Andrisonova-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.160.113",
)
gw = Host(hostname="K6A-DC3", os_family="windows", product="rdp-login-monitor", ipv4="192.168.160.40")
db_session.add_all([ws, gw])
db_session.commit()
event = Event(
event_id="ev-rdg-303",
host_id=gw.id,
occurred_at=datetime.now(timezone.utc),
received_at=datetime.now(timezone.utc),
category="auth",
type="rdg.connection.disconnected",
severity="info",
title="303",
summary="",
payload={},
details={"user": r"B26\papatramp", "internal_ip": ws.ipv4},
)
db_session.add(event)
db_session.commit()
response = client.post(f"/api/v1/events/{event.id}/actions/qwinsta", headers=jwt_headers)
assert response.status_code == 400
@@ -0,0 +1,378 @@
"""Tests for RDG 303 → workstation rdp.login.success correlation."""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from app.config import get_settings
from app.models import Host
from app.services.event_summary import event_to_summary
from app.services.host_sessions import event_session_terminated
from app.services.ingest import ingest_event
from app.services.rdg_workstation_session import (
SESSION_CLOSED_BY_RDG_AT_KEY,
SESSION_CLOSED_BY_RDG_EVENT_ID_KEY,
close_workstation_session_for_rdg_end,
find_rdg_end_after_workstation_login,
resolve_workstation_login_closed,
)
from tests.test_ingest import VALID_EVENT
def _payload(**overrides):
base = {
**VALID_EVENT,
"event_id": str(uuid.uuid4()),
"occurred_at": datetime.now(timezone.utc).isoformat(),
}
base.update(overrides)
return base
def _ingest(db, occurred_at: datetime, **overrides):
payload = _payload(**overrides)
payload["occurred_at"] = occurred_at.isoformat()
event, _ = ingest_event(db, payload)
db.flush()
return event
@pytest.fixture
def rdg_settings(monkeypatch):
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MIN_SEC", "1")
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MAX_SEC", "10")
monkeypatch.setenv("SAC_RDG_FLAP_DEDUP_SEC", "30")
get_settings.cache_clear()
yield
get_settings.cache_clear()
@pytest.fixture
def rdg_hosts(db_session):
ws = Host(
hostname="TSA-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.163.100",
)
gw = Host(
hostname="K6A-DC3",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.160.40",
)
db_session.add_all([ws, gw])
db_session.commit()
return ws, gw
def test_rdg_end_marks_workstation_login_closed_on_ingest(db_session, rdg_settings, rdg_hosts):
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
internal_ip = ws.ipv4
login = _ingest(
db_session,
t0 + timedelta(seconds=1),
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": internal_ip},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="1149",
details={"user": user},
)
end = _ingest(
db_session,
t0 + timedelta(hours=2),
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="RD Gateway event 303",
summary="303",
details={"user": user, "internal_ip": internal_ip},
)
assert login.details[SESSION_CLOSED_BY_RDG_AT_KEY] == end.occurred_at.isoformat()
assert login.details[SESSION_CLOSED_BY_RDG_EVENT_ID_KEY] == end.id
assert event_session_terminated(login, db=db_session) is True
summary = event_to_summary(login, db_session)
assert summary.session_terminated is True
def test_rdg_flap_does_not_close_workstation_login(db_session, rdg_settings, rdg_hosts):
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
internal_ip = ws.ipv4
details = {"user": user, "internal_ip": internal_ip}
login = _ingest(
db_session,
t0 + timedelta(seconds=1),
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": internal_ip},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="1149",
details={"user": user},
)
_ingest(
db_session,
t0,
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
_ingest(
db_session,
t0 + timedelta(seconds=4),
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
db_session.refresh(login)
assert SESSION_CLOSED_BY_RDG_AT_KEY not in (login.details or {})
assert event_session_terminated(login, db=db_session) is False
assert event_to_summary(login, db_session).session_terminated is False
def test_runtime_resolve_for_historical_login_without_flag(db_session, rdg_settings, rdg_hosts):
ws, gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
internal_ip = ws.ipv4
from app.models import Event
login = Event(
event_id=str(uuid.uuid4()),
host_id=ws.id,
occurred_at=t0 + timedelta(seconds=1),
received_at=t0,
category="auth",
type="rdp.login.success",
severity="info",
title="RDP login",
summary="1149",
payload={},
details={"user": "TSA"},
)
end = Event(
event_id=str(uuid.uuid4()),
host_id=gw.id,
occurred_at=t0 + timedelta(hours=2),
received_at=t0,
category="auth",
type="rdg.connection.disconnected",
severity="info",
title="303",
summary="303",
payload={},
details={"user": user, "internal_ip": internal_ip},
)
db_session.add_all([login, end])
db_session.commit()
assert resolve_workstation_login_closed(db_session, login) is True
assert find_rdg_end_after_workstation_login(db_session, login) is not None
assert event_to_summary(login, db_session).session_terminated is True
def test_user_mismatch_does_not_close_login(db_session, rdg_settings, rdg_hosts):
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
internal_ip = ws.ipv4
login = _ingest(
db_session,
t0,
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": internal_ip},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="1149",
details={"user": r"B26\Alice"},
)
end = _ingest(
db_session,
t0 + timedelta(hours=1),
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details={"user": r"B26\Bob", "internal_ip": internal_ip},
)
db_session.refresh(login)
assert close_workstation_session_for_rdg_end(db_session, end) is None
assert event_session_terminated(login, db=db_session) is False
def test_ip_mismatch_does_not_close_login(db_session, rdg_settings, rdg_hosts):
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
login = _ingest(
db_session,
t0,
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": ws.ipv4},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="1149",
details={"user": user},
)
end = _ingest(
db_session,
t0 + timedelta(hours=1),
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details={"user": user, "internal_ip": "192.168.163.200"},
)
db_session.refresh(login)
assert close_workstation_session_for_rdg_end(db_session, end) is None
assert event_session_terminated(login, db=db_session) is False
def test_empty_rcm1149_user_enriched_from_prior_rdg302(db_session, rdg_settings, rdg_hosts):
"""COMM-PC class: EventLog 1149 has empty Param1; RDG 302 already has the account."""
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\s.shelkovaya"
internal_ip = ws.ipv4
_ingest(
db_session,
t0,
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
type="rdg.connection.success",
category="auth",
severity="warning",
title="RDG 302",
summary="302",
details={"user": user, "internal_ip": internal_ip},
)
login = _ingest(
db_session,
t0 + timedelta(seconds=3),
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": internal_ip},
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
type="rdp.login.success",
category="auth",
severity="warning",
title="RDP connection (RCM 1149)",
summary="RCM 1149 - 192.168.160.40",
details={"user": "-", "ip_address": "192.168.160.40", "event_id_windows": 1149},
)
db_session.refresh(login)
assert login.details["user"] == user
assert login.details.get("user_enriched_from_rdg_event_id")
summary = event_to_summary(login, db_session)
assert summary.actor_user == user
def test_empty_rcm1149_backfilled_when_rdg302_arrives_later(db_session, rdg_settings, rdg_hosts):
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\s.shelkovaya"
internal_ip = ws.ipv4
login = _ingest(
db_session,
t0 + timedelta(seconds=1),
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": internal_ip},
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="RDP connection (RCM 1149)",
summary="RCM 1149 - 1.2.3.4",
details={"user": "-", "event_id_windows": 1149},
)
assert login.details["user"] == "-"
_ingest(
db_session,
t0,
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
type="rdg.connection.success",
category="auth",
severity="info",
title="RDG 302",
summary="302",
details={"user": user, "internal_ip": internal_ip},
)
db_session.refresh(login)
assert login.details["user"] == user
def test_empty_user_login_still_closed_by_rdg303(db_session, rdg_settings, rdg_hosts):
ws, _gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\s.shelkovaya"
internal_ip = ws.ipv4
login = _ingest(
db_session,
t0,
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": internal_ip},
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="RDP connection (RCM 1149)",
summary="RCM 1149",
details={"user": "-", "event_id_windows": 1149},
)
end = _ingest(
db_session,
t0 + timedelta(minutes=5),
host={"hostname": "K6A-DC3", "os_family": "windows"},
source={"product": "rdp-login-monitor", "product_version": "2.1.13-SAC"},
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details={"user": user, "internal_ip": internal_ip},
)
db_session.refresh(login)
assert login.details.get(SESSION_CLOSED_BY_RDG_AT_KEY) == end.occurred_at.isoformat()
assert event_session_terminated(login, db=db_session) is True
@@ -0,0 +1,242 @@
"""Tests for RDP flap auto-disconnect setting and service."""
import uuid
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from app.config import get_settings
from app.models import Host
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
from app.services.ingest import ingest_event
from app.services.problems import maybe_create_problem
from app.services.rdp_flap_auto_disconnect import maybe_auto_disconnect_stuck_rdp_session
from app.services.rdp_flap_settings import get_effective_rdp_flap_settings, upsert_rdp_flap_settings
from app.services.winrm_connect import WinRmCmdResult
from tests.test_ingest import VALID_EVENT
def _payload(**overrides):
base = {
**VALID_EVENT,
"event_id": str(uuid.uuid4()),
"occurred_at": datetime.now(timezone.utc).isoformat(),
}
base.update(overrides)
return base
def _ingest(db, occurred_at: datetime, **overrides):
payload = _payload(**overrides)
payload["occurred_at"] = occurred_at.isoformat()
event, _ = ingest_event(db, payload)
db.flush()
return event
@pytest.fixture
def rdg_settings(monkeypatch):
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MIN_SEC", "1")
monkeypatch.setenv("SAC_RDG_FLAP_WINDOW_MAX_SEC", "10")
monkeypatch.setenv("SAC_RDG_FLAP_DEDUP_SEC", "30")
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "secret")
get_settings.cache_clear()
yield
get_settings.cache_clear()
@pytest.fixture
def rdg_hosts(db_session):
ws = Host(
hostname="TSA-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.163.100",
)
gw = Host(
hostname="K6A-DC3",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.160.40",
)
db_session.add_all([ws, gw])
db_session.commit()
return ws, gw
def test_rdp_flap_settings_default_disabled(db_session):
cfg = get_effective_rdp_flap_settings(db_session)
assert cfg.auto_disconnect is False
assert cfg.source == "default"
def test_rdp_flap_settings_upsert(db_session):
upsert_rdp_flap_settings(db_session, auto_disconnect=True)
cfg = get_effective_rdp_flap_settings(db_session)
assert cfg.auto_disconnect is True
row = db_session.get(UiSettings, UI_SETTINGS_ROW_ID)
assert row is not None
assert row.auto_rdp_flap_disconnect is True
def test_auto_disconnect_skipped_when_disabled(db_session, rdg_settings, rdg_hosts, monkeypatch):
ws, gw = rdg_hosts
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
details = {"user": user, "internal_ip": ws.ipv4}
gw_payload = {
"host": {"hostname": gw.hostname, "os_family": "windows", "ipv4": gw.ipv4},
"source": {"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
}
_ingest(
db_session,
t0,
**gw_payload,
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
end = _ingest(
db_session,
t0 + timedelta(seconds=4),
**gw_payload,
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
maybe_create_problem(db_session, end)
mock_logoff = MagicMock()
monkeypatch.setattr("app.services.rdp_flap_auto_disconnect.execute_logoff_via_winrm", mock_logoff)
result = maybe_auto_disconnect_stuck_rdp_session(db_session, end)
assert result is None
mock_logoff.assert_not_called()
def test_auto_disconnect_rdg_flap_calls_logoff(db_session, rdg_settings, rdg_hosts, monkeypatch):
ws, gw = rdg_hosts
upsert_rdp_flap_settings(db_session, auto_disconnect=True)
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
details = {"user": user, "internal_ip": ws.ipv4}
gw_payload = {
"host": {"hostname": gw.hostname, "os_family": "windows", "ipv4": gw.ipv4},
"source": {"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
}
_ingest(
db_session,
t0,
**gw_payload,
type="rdg.connection.success",
category="auth",
severity="info",
title="302",
summary="302",
details=details,
)
_ingest(
db_session,
t0 + timedelta(seconds=1),
host={"hostname": ws.hostname, "os_family": "windows", "ipv4": ws.ipv4},
source={"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
type="rdp.login.success",
category="auth",
severity="info",
title="login",
summary="login",
details={"user": user},
)
end = _ingest(
db_session,
t0 + timedelta(seconds=4),
**gw_payload,
type="rdg.connection.disconnected",
category="auth",
severity="info",
title="303",
summary="303",
details=details,
)
maybe_create_problem(db_session, end)
qwinsta_stdout = "SESSIONNAME USERNAME ID STATE\n rdp-tcp#0 B26\\TSA 5 Active\n"
monkeypatch.setattr(
"app.services.rdp_flap_auto_disconnect.list_windows_sessions",
lambda host, cfg: (
[],
WinRmCmdResult(ok=True, message="ok", target=host.ipv4 or "", stdout=qwinsta_stdout),
),
)
mock_logoff = MagicMock(return_value=SimpleNamespace(status="completed", result_stderr=None, result_stdout="ok"))
monkeypatch.setattr("app.services.rdp_flap_auto_disconnect.execute_logoff_via_winrm", mock_logoff)
result = maybe_auto_disconnect_stuck_rdp_session(db_session, end)
assert result is not None
assert result.ok is True
assert result.session_ids == (5,)
mock_logoff.assert_called_once()
assert end.details["rdp_flap_auto_disconnect"]["ok"] is True
def test_auto_disconnect_direct_rdp_failed(db_session, rdg_settings, rdg_hosts, monkeypatch):
ws, _gw = rdg_hosts
upsert_rdp_flap_settings(db_session, auto_disconnect=True)
t0 = datetime.now(timezone.utc)
user = r"B26\TSA"
ws_payload = {
"host": {"hostname": ws.hostname, "os_family": "windows", "ipv4": ws.ipv4},
"source": {"product": "rdp-login-monitor", "product_version": "2.1.8-SAC"},
}
_ingest(
db_session,
t0,
**ws_payload,
type="rdp.login.success",
category="auth",
severity="info",
title="login",
summary="login",
details={"user": user},
)
failed = _ingest(
db_session,
t0 + timedelta(seconds=30),
**ws_payload,
type="rdp.login.failed",
category="auth",
severity="warning",
title="failed",
summary="failed",
details={"user": user},
)
qwinsta_stdout = "SESSIONNAME USERNAME ID STATE\n rdp-tcp#0 B26\\TSA 3 Active\n"
monkeypatch.setattr(
"app.services.rdp_flap_auto_disconnect.list_windows_sessions",
lambda host, cfg: (
[],
WinRmCmdResult(ok=True, message="ok", target=host.ipv4 or "", stdout=qwinsta_stdout),
),
)
monkeypatch.setattr(
"app.services.rdp_flap_auto_disconnect.terminate_windows_session",
lambda host, cfg, sid: WinRmCmdResult(ok=True, message="ok", target=host.ipv4 or ""),
)
result = maybe_auto_disconnect_stuck_rdp_session(db_session, failed)
assert result is not None
assert result.ok is True
assert result.session_ids == (3,)
+229
View File
@@ -0,0 +1,229 @@
"""Tests for direct RDP logoff → workstation rdp.login.success correlation."""
import uuid
from datetime import datetime, timedelta, timezone
from app.services.event_summary import event_to_summary
from app.services.host_sessions import event_session_terminated
from app.services.ingest import ingest_event
from app.services.rdp_session_logoff import (
SESSION_CLOSED_BY_LOGOFF_AT_KEY,
SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY,
close_workstation_session_for_rdp_logoff,
find_logoff_after_workstation_login,
resolve_workstation_login_closed_by_logoff,
)
from tests.test_ingest import VALID_EVENT
def _payload(**overrides):
base = {
**VALID_EVENT,
"event_id": str(uuid.uuid4()),
"occurred_at": datetime.now(timezone.utc).isoformat(),
}
base.update(overrides)
return base
def _ingest(db, occurred_at: datetime, **overrides):
payload = _payload(**overrides)
payload["occurred_at"] = occurred_at.isoformat()
event, _ = ingest_event(db, payload)
db.flush()
return event
def test_logoff_marks_workstation_login_closed_on_ingest(db_session):
t0 = datetime.now(timezone.utc)
user = r"B26\papatramp"
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0 + timedelta(seconds=1),
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": user, "ip_address": "192.168.160.3", "logon_type": 10},
)
logoff = _ingest(
db_session,
t0 + timedelta(hours=1),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4634",
details={
"user": user,
"ip_address": "192.168.160.3",
"logon_type": 10,
"event_id_windows": 4634,
},
)
assert login.details[SESSION_CLOSED_BY_LOGOFF_AT_KEY] == logoff.occurred_at.isoformat()
assert login.details[SESSION_CLOSED_BY_LOGOFF_EVENT_ID_KEY] == logoff.id
assert event_session_terminated(login, db=db_session) is True
assert event_to_summary(login, db_session).session_terminated is True
def test_user_mismatch_does_not_close_login(db_session):
t0 = datetime.now(timezone.utc)
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0,
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": r"B26\Alice"},
)
logoff = _ingest(
db_session,
t0 + timedelta(hours=1),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4634",
details={"user": r"B26\Bob", "logon_type": 10, "event_id_windows": 4634},
)
db_session.refresh(login)
assert close_workstation_session_for_rdp_logoff(db_session, logoff) is None
assert event_session_terminated(login, db=db_session) is False
def test_ip_mismatch_does_not_close_login_when_both_ips_present(db_session):
t0 = datetime.now(timezone.utc)
user = r"B26\papatramp"
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0,
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": user, "ip_address": "192.168.160.3", "logon_type": 10},
)
logoff = _ingest(
db_session,
t0 + timedelta(hours=1),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4634",
details={"user": user, "ip_address": "192.168.160.99", "logon_type": 10, "event_id_windows": 4634},
)
db_session.refresh(login)
assert close_workstation_session_for_rdp_logoff(db_session, logoff) is None
assert event_session_terminated(login, db=db_session) is False
def test_runtime_resolve_for_historical_login_without_flag(db_session):
from app.models import Event, Host
t0 = datetime.now(timezone.utc)
user = r"B26\papatramp"
host = Host(
hostname="BIV-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.165.39",
)
db_session.add(host)
db_session.commit()
login = Event(
event_id=str(uuid.uuid4()),
host_id=host.id,
occurred_at=t0 + timedelta(seconds=1),
received_at=t0,
category="auth",
type="rdp.login.success",
severity="info",
title="RDP login",
summary="4624",
payload={},
details={"user": "papatramp", "ip_address": "192.168.160.3"},
)
logoff = Event(
event_id=str(uuid.uuid4()),
host_id=host.id,
occurred_at=t0 + timedelta(hours=2),
received_at=t0,
category="auth",
type="rdp.session.logoff",
severity="info",
title="4634",
summary="4634",
payload={},
details={"user": user, "ip_address": "192.168.160.3", "event_id_windows": 4634},
)
db_session.add_all([login, logoff])
db_session.commit()
assert resolve_workstation_login_closed_by_logoff(db_session, login) is True
assert find_logoff_after_workstation_login(db_session, login) is not None
assert event_to_summary(login, db_session).session_terminated is True
def test_sam_domain_user_match_on_logoff(db_session):
t0 = datetime.now(timezone.utc)
host = {"hostname": "BIV-PC", "os_family": "windows", "ipv4": "192.168.165.39"}
source = {"product": "rdp-login-monitor", "product_version": "2.1.11-SAC"}
login = _ingest(
db_session,
t0,
host=host,
source=source,
type="rdp.login.success",
category="auth",
severity="info",
title="RDP login",
summary="4624",
details={"user": r"B26\papatramp", "logon_type": 10},
)
_ingest(
db_session,
t0 + timedelta(minutes=30),
host=host,
source=source,
type="rdp.session.logoff",
category="auth",
severity="info",
title="RDP session logoff",
summary="4647",
details={"user": "papatramp", "logon_type": 10, "event_id_windows": 4647},
)
db_session.refresh(login)
assert event_session_terminated(login, db=db_session) is True
+78
View File
@@ -0,0 +1,78 @@
"""Retention purge service."""
import uuid
from datetime import datetime, timedelta, timezone
from app.config import Settings
from app.models import Event, Host, Problem
from app.services.retention import purge_old_data
def _host(db, name: str) -> Host:
h = Host(
hostname=name,
os_family="linux",
product="ssh-monitor",
last_seen_at=datetime.now(timezone.utc),
)
db.add(h)
db.flush()
return h
def test_purge_old_events_and_resolved_problems(db_session):
h = _host(db_session, "retention-host")
now = datetime.now(timezone.utc)
old_event = now - timedelta(days=120)
old_problem = now - timedelta(days=200)
_ev = Event(
event_id=str(uuid.uuid4()),
host_id=h.id,
occurred_at=old_event,
received_at=old_event,
category="agent",
type="agent.heartbeat",
severity="info",
title="old",
summary="s",
payload={},
)
db_session.add(_ev)
db_session.add(
Problem(
host_id=h.id,
title="resolved old",
summary="s",
severity="info",
status="resolved",
rule_id="rule:test",
fingerprint="fp-ret-old",
event_count=1,
last_seen_at=old_problem,
created_at=old_problem,
updated_at=old_problem,
)
)
db_session.add(
Problem(
host_id=h.id,
title="open stays",
summary="s",
severity="high",
status="open",
rule_id="rule:test",
fingerprint="fp-ret-open",
event_count=1,
last_seen_at=now,
created_at=now,
updated_at=now,
)
)
db_session.commit()
settings = Settings(sac_events_retention_days=90, sac_problems_retention_days=180)
stats = purge_old_data(db_session, settings)
assert stats["events_deleted"] == 1
assert stats["problems_deleted"] == 1
assert db_session.query(Event).count() == 0
assert db_session.query(Problem).filter(Problem.status == "open").count() == 1
+52
View File
@@ -0,0 +1,52 @@
"""Security bootstrap and API key hashing."""
import hashlib
import pytest
from app.auth.api_key import _legacy_hash_api_key, hash_api_key, verify_api_key_hash
from app.security_bootstrap import validate_security_settings
from app.config import Settings
def test_hash_api_key_uses_hmac(monkeypatch):
monkeypatch.setenv("JWT_SECRET", "unit-test-secret")
from app.config import get_settings
get_settings.cache_clear()
raw = "sac_example_key"
assert hash_api_key(raw) != _legacy_hash_api_key(raw)
assert verify_api_key_hash(raw, hash_api_key(raw))
get_settings.cache_clear()
def test_verify_api_key_hash_accepts_legacy_sha256(monkeypatch):
monkeypatch.setenv("JWT_SECRET", "unit-test-secret")
from app.config import get_settings
get_settings.cache_clear()
raw = "sac_legacy_key"
legacy = hashlib.sha256(raw.encode("utf-8")).hexdigest()
assert verify_api_key_hash(raw, legacy)
get_settings.cache_clear()
def test_validate_security_settings_rejects_weak_jwt():
settings = Settings(
jwt_secret="change-me-in-production",
sac_public_url="https://sac.example.com",
cors_origins="https://sac.example.com",
sac_security_enforce=True,
)
with pytest.raises(RuntimeError, match="JWT_SECRET"):
validate_security_settings(settings)
def test_validate_security_settings_rejects_wildcard_cors(monkeypatch):
settings = Settings(
jwt_secret="strong-secret-value",
sac_public_url="https://sac.example.com",
cors_origins="*",
sac_security_enforce=True,
)
with pytest.raises(RuntimeError, match="CORS_ORIGINS"):
validate_security_settings(settings)
+20
View File
@@ -0,0 +1,20 @@
"""Tests for session_duration_sec extract/format."""
from app.services.session_duration import extract_session_duration_sec, format_session_duration
def test_extract_session_duration_sec():
assert extract_session_duration_sec({"session_duration_sec": 18324}) == 18324
assert extract_session_duration_sec({"session_duration_sec": "42"}) == 42
assert extract_session_duration_sec({"session_duration_sec": ""}) is None
assert extract_session_duration_sec({}) is None
assert extract_session_duration_sec(None) is None
assert extract_session_duration_sec({"session_duration_sec": -1}) is None
def test_format_session_duration():
assert format_session_duration(0) == "00:00:00"
assert format_session_duration(18324) == "05:05:24"
assert format_session_duration(2428) == "00:40:28"
assert format_session_duration(86400 + 85) == "1д 00:01:25"
assert format_session_duration(2 * 86400 + 3661) == "2д 01:01:01"
+197
View File
@@ -0,0 +1,197 @@
"""GET/PUT/POST /api/v1/settings/notifications"""
from unittest.mock import patch
from app.config import get_settings
from app.models.notification_channel import NotificationChannel
from app.services.notification_settings import get_effective_telegram_config
def test_get_notification_settings_masks_secrets(jwt_headers, client, monkeypatch):
monkeypatch.setenv("TELEGRAM_ENABLED", "true")
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "1234567890:ABCDEFghij")
monkeypatch.setenv("TELEGRAM_CHAT_ID", "2843230")
monkeypatch.setenv("TELEGRAM_MIN_SEVERITY", "warning")
get_settings.cache_clear()
response = client.get("/api/v1/settings/notifications", headers=jwt_headers)
assert response.status_code == 200
body = response.json()
assert body["telegram"]["enabled"] is True
assert body["telegram"]["configured"] is True
assert body["telegram"]["min_severity"] == "warning"
assert body["telegram"]["source"] == "env"
assert body["telegram"]["bot_token_hint"].endswith("ghij")
assert "policy" in body
assert body["policy"]["min_severity"] == "warning"
assert "webhook" in body
assert body["webhook"]["enabled"] is False
assert "email" in body
assert body["email"]["enabled"] is False
def test_put_email_settings_persists_db(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SMTP_ENABLED", "false")
monkeypatch.setenv("SMTP_HOST", "")
get_settings.cache_clear()
response = client.put(
"/api/v1/settings/notifications/email",
headers=jwt_headers,
json={
"enabled": True,
"smtp_host": "smtp.example.com",
"smtp_port": 587,
"smtp_user": "monitor",
"smtp_password": "secret",
"mail_from": "sac@example.com",
"mail_to": "admin@example.com,ops@example.com",
"smtp_starttls": True,
"smtp_ssl": False,
},
)
assert response.status_code == 200
body = response.json()
assert body["source"] == "db"
assert body["configured"] is True
assert body["smtp_port"] == 587
row = db_session.get(NotificationChannel, "email")
assert row is not None
assert row.smtp_host == "smtp.example.com"
assert row.mail_from == "sac@example.com"
def test_post_email_test_success(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SMTP_ENABLED", "false")
get_settings.cache_clear()
db_session.add(
NotificationChannel(
channel="email",
enabled=True,
min_severity="warning",
smtp_host="smtp.example.com",
smtp_port=587,
mail_from="sac@example.com",
mail_to="admin@example.com",
)
)
db_session.commit()
with patch("app.api.v1.settings.send_email_test_message") as mock_test:
response = client.post("/api/v1/settings/notifications/email/test", headers=jwt_headers)
assert response.status_code == 200
assert response.json()["ok"] is True
mock_test.assert_called_once()
def test_put_webhook_settings_persists_db(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("WEBHOOK_ENABLED", "false")
monkeypatch.setenv("WEBHOOK_URL", "")
get_settings.cache_clear()
response = client.put(
"/api/v1/settings/notifications/webhook",
headers=jwt_headers,
json={
"enabled": True,
"url": "https://example.com/hooks/sac",
"secret_header": "X-SAC-Token",
"secret": "supersecret",
},
)
assert response.status_code == 200
body = response.json()
assert body["source"] == "db"
assert body["configured"] is True
row = db_session.get(NotificationChannel, "webhook")
assert row is not None
assert row.webhook_url == "https://example.com/hooks/sac"
assert row.webhook_secret_header == "X-SAC-Token"
def test_post_webhook_test_success(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("WEBHOOK_ENABLED", "false")
get_settings.cache_clear()
db_session.add(
NotificationChannel(
channel="webhook",
enabled=True,
min_severity="warning",
webhook_url="https://example.com/hook",
)
)
db_session.commit()
with patch("app.api.v1.settings.send_webhook_test_message") as mock_test:
response = client.post("/api/v1/settings/notifications/webhook/test", headers=jwt_headers)
assert response.status_code == 200
assert response.json()["ok"] is True
mock_test.assert_called_once()
def test_put_telegram_settings_persists_db(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("TELEGRAM_ENABLED", "false")
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "")
monkeypatch.setenv("TELEGRAM_CHAT_ID", "")
monkeypatch.setenv("TELEGRAM_MIN_SEVERITY", "high")
get_settings.cache_clear()
response = client.put(
"/api/v1/settings/notifications/telegram",
headers=jwt_headers,
json={
"enabled": True,
"bot_token": "1234567890:TESTTOKEN",
"chat_id": "999001",
},
)
assert response.status_code == 200
body = response.json()
assert body["source"] == "db"
assert body["enabled"] is True
row = db_session.get(NotificationChannel, "telegram")
assert row is not None
assert row.bot_token == "1234567890:TESTTOKEN"
assert row.chat_id == "999001"
cfg = get_effective_telegram_config(db_session)
assert cfg.source == "db"
def test_post_telegram_test_success(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("TELEGRAM_ENABLED", "true")
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "tok")
monkeypatch.setenv("TELEGRAM_CHAT_ID", "1")
get_settings.cache_clear()
db_session.add(
NotificationChannel(
channel="telegram",
enabled=True,
min_severity="warning",
bot_token="1234567890:ABCDEF",
chat_id="2843230",
)
)
db_session.commit()
with patch("app.api.v1.settings.send_telegram_test_message") as mock_test:
response = client.post("/api/v1/settings/notifications/telegram/test", headers=jwt_headers)
assert response.status_code == 200
assert response.json()["ok"] is True
mock_test.assert_called_once()
def test_post_telegram_test_not_configured(jwt_headers, client, monkeypatch):
monkeypatch.setenv("TELEGRAM_ENABLED", "false")
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "")
monkeypatch.setenv("TELEGRAM_CHAT_ID", "")
get_settings.cache_clear()
response = client.post("/api/v1/settings/notifications/telegram/test", headers=jwt_headers)
assert response.status_code == 400
+336
View File
@@ -0,0 +1,336 @@
"""SSH connect service tests (paramiko mocked via sys.modules)."""
import sys
from unittest.mock import MagicMock
from app.models import Host
from app.services import ssh_connect
from app.services.ssh_connect import (
HostNotLinuxError,
HostTargetMissingError,
_remote_shell_command,
iter_ssh_targets,
run_ssh_command,
run_ssh_monitor_update,
)
class _AuthError(Exception):
pass
def _install_fake_paramiko(monkeypatch, *, connect_side_effect=None, exec_setup=None):
mock_client_cls = MagicMock()
client = MagicMock()
if connect_side_effect is not None:
client.connect.side_effect = connect_side_effect
if exec_setup is not None:
exec_setup(client)
mock_client_cls.return_value = client
fake = MagicMock()
fake.SSHClient = mock_client_cls
fake.AutoAddPolicy = MagicMock()
fake.RejectPolicy = MagicMock()
fake.AuthenticationException = _AuthError
monkeypatch.setitem(sys.modules, "paramiko", fake)
return client
def test_ssh_output_hostname_ignores_motd():
stdout = "Welcome!\nFASTPANEL\n\ncz-server\n"
assert ssh_connect._ssh_output_hostname(stdout) == "cz-server"
def test_remote_shell_command_non_login_for_sessions():
cmd, needs_pw = _remote_shell_command("root", "loginctl list-sessions", login_shell=False)
assert cmd == "bash -c 'loginctl list-sessions'"
assert needs_pw is False
def test_remote_shell_command_non_root_probe_has_no_sudo():
cmd, needs_pw = _remote_shell_command("deploy", "hostname", need_root=False)
assert "sudo" not in cmd
assert "hostname" in cmd
assert needs_pw is False
def test_remote_shell_command_non_root_privileged_uses_sudo_s():
cmd, needs_pw = _remote_shell_command("deploy", "/opt/scripts/update_ssh_monitor.sh", need_root=True)
assert "sudo -S" in cmd
assert "update_ssh_monitor.sh" in cmd
assert needs_pw is True
assert "secret" not in cmd
def test_remote_shell_command_root_skips_sudo_even_when_need_root():
cmd, needs_pw = _remote_shell_command("root", "/opt/scripts/update_ssh_monitor.sh", need_root=True)
assert "sudo" not in cmd
assert needs_pw is False
def test_probe_ssh_connection_non_root_does_not_use_sudo(monkeypatch):
captured: list[str] = []
def setup(client):
stdout = MagicMock()
stdout.read.return_value = b"ubabuba\n"
stdout.channel.recv_exit_status.return_value = 0
stderr = MagicMock()
stderr.read.return_value = b""
def capture_exec(cmd, **kwargs):
captured.append(cmd)
return (None, stdout, stderr)
client.exec_command.side_effect = capture_exec
_install_fake_paramiko(monkeypatch, exec_setup=setup)
result = ssh_connect.test_ssh_connection(target="10.10.36.9", user="deploy", password="pw")
assert result.ok is True
assert captured
assert "sudo" not in captured[0]
def test_iter_ssh_targets_skips_human_display_name(monkeypatch):
monkeypatch.setattr(ssh_connect, "_ssh_name_resolves", lambda name: True)
host = Host(
hostname="ubabuba",
display_name="Ubabuba Kalina (10.10.36.9)",
os_family="linux",
product="ssh-monitor",
ipv4="10.10.36.9",
)
targets = iter_ssh_targets(host)
assert "Ubabuba Kalina (10.10.36.9)" not in targets
assert "10.10.36.9" in targets
def test_iter_ssh_targets_skips_unresolvable_hostname(monkeypatch):
def resolves(name: str) -> bool:
return ssh_connect._is_ipv4(name)
monkeypatch.setattr(ssh_connect, "_ssh_name_resolves", resolves)
host = Host(
hostname="ubabuba",
os_family="linux",
product="ssh-monitor",
ipv4="10.10.36.9",
)
targets = iter_ssh_targets(host)
assert targets == ["10.10.36.9"]
def test_iter_ssh_targets_rejects_windows():
host = Host(hostname="PC", os_family="windows", product="rdp-login-monitor", ipv4="1.2.3.4")
try:
iter_ssh_targets(host)
raise AssertionError("expected HostNotLinuxError")
except HostNotLinuxError:
pass
def test_iter_ssh_targets_requires_address():
host = Host(hostname="", os_family="linux", product="ssh-monitor", ipv4=None)
try:
iter_ssh_targets(host)
raise AssertionError("expected HostTargetMissingError")
except HostTargetMissingError:
pass
def test_run_ssh_command_retries_transient_no_existing_session(monkeypatch):
attempts = {"count": 0}
class _NoSessionError(Exception):
pass
def connect_side_effect(*args, **kwargs):
attempts["count"] += 1
if attempts["count"] == 1:
raise _NoSessionError("No existing session")
def setup(client):
stdout = MagicMock()
stdout.read.return_value = b"ready\n"
stdout.channel.recv_exit_status.return_value = 0
stderr = MagicMock()
stderr.read.return_value = b""
def exec_ok(cmd, **kwargs):
return (None, stdout, stderr)
client.exec_command.side_effect = exec_ok
fake = MagicMock()
fake.SSHClient = MagicMock()
fake.AutoAddPolicy = MagicMock()
fake.RejectPolicy = MagicMock()
fake.AuthenticationException = _AuthError
def make_client():
client = MagicMock()
client.connect.side_effect = connect_side_effect
setup(client)
return client
fake.SSHClient.side_effect = make_client
monkeypatch.setitem(sys.modules, "paramiko", fake)
result = run_ssh_command(
target="185.87.149.9",
user="root",
password="pw",
remote_cmd="hostname",
)
assert result.ok is True
assert attempts["count"] == 2
def test_probe_ssh_connection_success(monkeypatch):
def setup(client):
stdout = MagicMock()
stdout.read.return_value = b"ubabuba\n"
stdout.channel.recv_exit_status.return_value = 0
stderr = MagicMock()
stderr.read.return_value = b""
client.exec_command.return_value = (None, stdout, stderr)
client = _install_fake_paramiko(monkeypatch, exec_setup=setup)
result = ssh_connect.test_ssh_connection(target="ubabuba", user="root", password="pw")
assert result.ok is True
assert "hostname=ubabuba" in result.message
client.connect.assert_called_once()
client.close.assert_called_once()
def test_run_ssh_command_auth_failure(monkeypatch):
client = _install_fake_paramiko(monkeypatch, connect_side_effect=_AuthError("bad creds"))
result = run_ssh_command(target="10.0.0.1", user="root", password="wrong", remote_cmd="hostname")
assert result.ok is False
assert "auth failed" in result.message.lower()
client.close.assert_called_once()
def test_run_ssh_monitor_update_missing_script_bootstraps(monkeypatch):
calls = 0
def fake_run(**kwargs):
nonlocal calls
calls += 1
if calls == 1:
return ssh_connect.SshCommandResult(
ok=True,
message="ok",
target="h",
stdout="missing\n",
)
if calls == 2:
cmd = kwargs["remote_cmd"]
assert "git clone" in cmd
assert "update_ssh_monitor.sh" in cmd
assert kwargs.get("need_root") is True
return ssh_connect.SshCommandResult(
ok=True,
message="SSH OK",
target="185.87.149.9",
stdout="SUMMARY updated 2.1.0-SAC\n",
exit_code=0,
)
return ssh_connect.SshCommandResult(
ok=True,
message="ok",
target="185.87.149.9",
stdout="2.1.0-SAC\n",
exit_code=0,
)
monkeypatch.setattr(ssh_connect, "run_ssh_command", fake_run)
result = run_ssh_monitor_update(target="185.87.149.9", user="root", password="pw")
assert result.ok is True
assert result.agent_version == "2.1.0-SAC"
assert "Bootstrap" in result.message
assert calls == 3
def test_run_ssh_monitor_update_missing_script(monkeypatch):
def fake_run(**kwargs):
if kwargs["remote_cmd"].startswith("test -x"):
return ssh_connect.SshCommandResult(
ok=True,
message="ok",
target="h",
stdout="missing\n",
)
return ssh_connect.SshCommandResult(
ok=False,
message="git clone failed",
target="h",
stderr="fatal: could not read",
exit_code=128,
)
monkeypatch.setattr(ssh_connect, "run_ssh_command", fake_run)
result = run_ssh_monitor_update(target="ubabuba", user="root", password="pw")
assert result.ok is False
assert "bootstrap failed" in result.message.lower()
def test_run_ssh_monitor_update_runs_script(monkeypatch):
calls = 0
def fake_run(**kwargs):
nonlocal calls
calls += 1
if calls == 1:
return ssh_connect.SshCommandResult(
ok=True,
message="ok",
target="h",
stdout="ready\n",
)
if calls == 2:
cmd = kwargs["remote_cmd"]
assert "git -C" in cmd
assert "kalinamall" in cmd
assert "REPO_URL=" in cmd
assert "git.kalinamall.ru" in cmd
assert "update_ssh_monitor.sh" in cmd
assert kwargs.get("need_root") is True
return ssh_connect.SshCommandResult(
ok=True,
message="SSH OK",
target="ubabuba",
stdout="SUMMARY updated 2.1.0-SAC\n",
exit_code=0,
)
return ssh_connect.SshCommandResult(
ok=True,
message="ok",
target="ubabuba",
stdout='2.1.0-SAC\n',
exit_code=0,
)
monkeypatch.setattr(ssh_connect, "run_ssh_command", fake_run)
result = run_ssh_monitor_update(
target="ubabuba",
user="root",
password="pw",
repo_url="https://git.papatramp.ru/PapaTramp/ssh-monitor.git",
)
assert result.ok is True
assert result.agent_version == "2.1.0-SAC"
assert calls == 3
def test_parse_ssh_monitor_version_text():
assert ssh_connect.parse_ssh_monitor_version_text("SSH_MONITOR_VERSION=2.1.0-SAC") == "2.1.0-SAC"
assert ssh_connect.parse_ssh_monitor_version_text("SUMMARY updated 2.1.1-SAC done") == "2.1.1-SAC"
assert ssh_connect.parse_ssh_monitor_version_text("no version") is None
+30
View File
@@ -0,0 +1,30 @@
"""GET /api/v1/system/stats"""
from app.services.ui_settings import upsert_ui_settings
def test_system_stats_requires_auth(client):
response = client.get("/api/v1/system/stats")
assert response.status_code == 401
def test_system_stats_visible_default(jwt_headers, client):
response = client.get("/api/v1/system/stats", headers=jwt_headers)
assert response.status_code == 200
body = response.json()
assert body["visible"] is True
assert "collected_at" in body
assert "database" in body
assert body["database"]["status"] in ("ok", "error")
assert "app" in body
assert "events_last_hour" in body["app"]
assert "problems_open" in body["app"]
def test_system_stats_respects_ui_setting(jwt_headers, client, db_session):
upsert_ui_settings(db_session, show_sidebar_system_stats=False)
db_session.commit()
response = client.get("/api/v1/system/stats", headers=jwt_headers)
assert response.status_code == 200
assert response.json()["visible"] is False
+76
View File
@@ -0,0 +1,76 @@
"""Tests for SAC Telegram notification helpers."""
from datetime import datetime, timezone
from unittest.mock import patch
from app.models import Event, Problem
from app.services import telegram_notify
from app.services.notification_settings import TelegramConfig
def _telegram_cfg(*, min_severity: str) -> TelegramConfig:
return TelegramConfig(
enabled=True,
bot_token="test-token",
chat_id="123",
min_severity=min_severity,
source="env",
)
def test_notify_event_respects_min_severity():
sent: list[str] = []
def fake_send(message: str, **kwargs) -> None:
sent.append(message)
event = Event(
event_id="00000000-0000-4000-8000-000000000001",
host_id=1,
occurred_at=datetime(2026, 5, 28, 12, 0, tzinfo=timezone.utc),
category="auth",
type="rdp.login.success",
severity="info",
title="ok",
summary="info event",
payload={},
)
cfg = _telegram_cfg(min_severity="warning")
with patch.object(telegram_notify, "get_effective_telegram_config", return_value=cfg):
with patch.object(telegram_notify, "send_telegram_text", side_effect=fake_send):
telegram_notify.notify_event(event)
assert sent == []
event.severity = "warning"
with patch.object(telegram_notify, "get_effective_telegram_config", return_value=cfg):
with patch.object(telegram_notify, "send_telegram_text", side_effect=fake_send):
telegram_notify.notify_event(event)
assert len(sent) == 1
def test_notify_problem_respects_min_severity():
sent: list[str] = []
def fake_send(message: str, **kwargs) -> None:
sent.append(message)
problem = Problem(
title="brute",
summary="burst",
severity="warning",
status="open",
fingerprint="fp1",
)
cfg = _telegram_cfg(min_severity="high")
with patch.object(telegram_notify, "get_effective_telegram_config", return_value=cfg):
with patch.object(telegram_notify, "send_telegram_text", side_effect=fake_send):
telegram_notify.notify_problem(problem)
assert sent == []
problem.severity = "high"
with patch.object(telegram_notify, "get_effective_telegram_config", return_value=cfg):
with patch.object(telegram_notify, "send_telegram_text", side_effect=fake_send):
telegram_notify.notify_problem(problem)
assert len(sent) == 1
+418
View File
@@ -0,0 +1,418 @@
"""Telegram HTML templates (notif-30)."""
from datetime import datetime, timezone
from app.models import Event, Host
from app.services.telegram_templates import (
format_event_mobile_push,
format_event_telegram_html,
format_rdp_login_html,
html_escape,
telegram_html_to_plaintext,
)
def test_html_escape_special_chars():
assert html_escape("a<b>&") == "a&lt;b&gt;&amp;"
def test_sanitize_telegram_html_keeps_bold():
from app.services.telegram_templates import sanitize_telegram_html
assert sanitize_telegram_html("<b>title</b><br>line") == "<b>title</b>\nline"
def test_rdp_failed_template_includes_user_ip_logon():
host = Host(hostname="K6A-DC3", display_name="UNMS Kalina", os_family="windows")
event = Event(
event_id="00000000-0000-4000-8000-000000000501",
host_id=1,
host=host,
occurred_at=datetime(2026, 5, 29, 10, 30, 0, tzinfo=timezone.utc),
category="auth",
type="rdp.login.failed",
severity="warning",
title="RDP login failed",
summary="4625 user from 10.0.0.5",
details={
"user": "DOMAIN\\admin",
"ip_address": "10.0.0.5",
"logon_type": 10,
"event_id_windows": 4625,
"workstation_name": "CLIENT01",
},
payload={},
)
text = format_rdp_login_html(event)
assert "НЕУДАЧНАЯ" in text
assert "DOMAIN\\admin" in text or "DOMAIN" in text
assert "10.0.0.5" in text
assert "Удаленный интерактивный" in text
assert "(10)" in text
assert "UNMS Kalina" in text
assert "CLIENT01" in text
assert "4625" in text
assert "<b>" in text
def test_rdp_login_hides_redundant_workstation_when_matches_hostname():
host = Host(
hostname="ITIS198",
display_name="ITIS (192.168.160.198)",
os_family="windows",
)
event = Event(
event_id="00000000-0000-4000-8000-000000000508",
host_id=1,
host=host,
occurred_at=datetime(2026, 5, 31, 18, 48, 52, tzinfo=timezone.utc),
category="auth",
type="rdp.login.success",
severity="info",
title="RDP login",
summary="papatramp",
details={
"user": "papatramp",
"ip_address": "192.168.160.3",
"logon_type": 10,
"workstation_name": "ITIS198",
"event_id_windows": 4624,
},
payload={},
)
text = format_rdp_login_html(event)
assert "ITIS" in text
assert "192.168.160.3" in text
assert "Рабочая станция" not in text
assert "ITIS198" not in text
def test_rdp_login_shows_workstation_when_client_differs():
host = Host(hostname="ITIS198", display_name="ITIS", os_family="windows")
event = Event(
event_id="00000000-0000-4000-8000-000000000509",
host_id=1,
host=host,
occurred_at=datetime(2026, 5, 31, 18, 48, 52, tzinfo=timezone.utc),
category="auth",
type="rdp.login.success",
severity="info",
title="RDP login",
summary="papatramp",
details={
"user": "papatramp",
"ip_address": "192.168.160.3",
"logon_type": 10,
"workstation_name": "NEW-ADMIN-PC",
"event_id_windows": 4624,
},
payload={},
)
text = format_rdp_login_html(event)
assert "Рабочая станция" in text
assert "NEW-ADMIN-PC" in text
def test_rdp_success_template():
event = Event(
event_id="00000000-0000-4000-8000-000000000502",
host_id=1,
occurred_at=datetime(2026, 5, 29, 11, 0, tzinfo=timezone.utc),
category="auth",
type="rdp.login.success",
severity="info",
title="ok",
summary="ok",
details={"user": "u1", "ip_address": "1.2.3.4", "logon_type": 3},
payload={},
)
text = format_event_telegram_html(event)
assert "УСПЕШНЫЙ" in text
assert "Сеть/RDP" in text
assert "📡 Оповещение: SAC (Security Alert Center)" in text
def test_event_telegram_includes_sac_source_for_sac_daily_report():
host = Host(hostname="h1", display_name="H1", os_family="linux", product="ssh-monitor")
event = Event(
event_id="00000000-0000-4000-8000-000000000504",
host_id=1,
host=host,
occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc),
category="report",
type="report.daily.ssh",
severity="info",
title="Отчёт",
summary="short",
details={"generated_by": "sac", "report_body": "📊 ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH МОНИТОРИНГА\nline"},
payload={},
)
text = format_event_telegram_html(event)
assert "📡 Оповещение: SAC (Security Alert Center)" in text
def test_telegram_html_to_plaintext_strips_tags():
assert telegram_html_to_plaintext("<b>✅ Агент запущен</b>\n🏢 Хост: WIN01") == (
"✅ Агент запущен\n🏢 Хост: WIN01"
)
def test_format_event_mobile_push_lifecycle_includes_trigger():
host = Host(hostname="WIN01", display_name="K6A-DC3", os_family="windows")
event = Event(
event_id="00000000-0000-4000-8000-000000000509",
host_id=1,
host=host,
occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc),
category="agent",
type="agent.lifecycle",
severity="info",
title="started",
summary="Мониторинг запущен",
details={"lifecycle": "started", "trigger": "deploy_recycle", "telegram_via": "sac"},
payload={"source": {"product": "rdp-login-monitor", "product_version": "1.2.30-SAC"}},
)
title, body = format_event_mobile_push(event)
assert title == "✅ Агент запущен"
assert "K6A-DC3" in body
assert "1.2.30-SAC" in body
assert "обновление скрипта" in body
assert "📡 Оповещение: SAC" in body
def test_format_event_mobile_push_uses_notification_body():
host = Host(hostname="WIN01", display_name="K6A-DC3", os_family="windows")
body_text = "✅ Мониторинг логинов ЗАПУЩЕН\n🖥️ Сервер: K6A-DC3 (10.0.0.1)"
event = Event(
event_id="00000000-0000-4000-8000-000000000510",
host_id=1,
host=host,
occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc),
category="agent",
type="agent.lifecycle",
severity="info",
title="started",
summary="Мониторинг запущен",
details={
"lifecycle": "started",
"trigger": "boot",
"notification_body": body_text,
"telegram_via": "sac",
},
payload={"source": {"product": "rdp-login-monitor", "product_version": "1.2.31-SAC"}},
)
title, body = format_event_mobile_push(event)
assert title == "✅ Мониторинг логинов ЗАПУЩЕН"
assert "K6A-DC3 (10.0.0.1)" in body
def test_lifecycle_started_template():
host = Host(hostname="WIN01", display_name="K6A-DC3", os_family="windows")
event = Event(
event_id="00000000-0000-4000-8000-000000000505",
host_id=1,
host=host,
occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc),
category="agent",
type="agent.lifecycle",
severity="info",
title="started",
summary="Мониторинг запущен",
details={"lifecycle": "started", "trigger": "boot", "telegram_via": "sac"},
payload={"source": {"product": "rdp-login-monitor", "product_version": "1.2.30-SAC"}},
)
text = format_event_telegram_html(event)
assert "Агент запущен" in text
assert "K6A-DC3" in text
assert "1.2.30-SAC" in text
assert "загрузка ОС" in text
assert "📡 Оповещение: SAC (Security Alert Center)" in text
def test_lifecycle_notification_body_renders_full_text():
host = Host(hostname="WIN01", display_name="K6A-DC3", os_family="windows")
body = "✅ Мониторинг логинов ЗАПУЩЕН\n🖥️ Сервер: K6A-DC3 (10.0.0.1)\n🕐 Время запуска: 28.05.2026 12:00:00"
event = Event(
event_id="00000000-0000-4000-8000-000000000507",
host_id=1,
host=host,
occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc),
category="agent",
type="agent.lifecycle",
severity="info",
title="started",
summary="Мониторинг запущен",
details={"lifecycle": "started", "trigger": "boot", "notification_body": body, "telegram_via": "sac"},
payload={"source": {"product": "rdp-login-monitor", "product_version": "1.2.31-SAC"}},
)
text = format_event_telegram_html(event)
assert "✅ Мониторинг логинов ЗАПУЩЕН" in text
assert "K6A-DC3 (10.0.0.1)" in text
assert "Агент запущен" not in text
assert "📡 Оповещение: SAC (Security Alert Center)" in text
def test_lifecycle_footer_uses_telegram_via_agent_when_present():
host = Host(hostname="WIN01", display_name="H1", os_family="windows")
event = Event(
event_id="00000000-0000-4000-8000-000000000506",
host_id=1,
host=host,
occurred_at=datetime(2026, 5, 29, 9, 0, tzinfo=timezone.utc),
category="agent",
type="agent.lifecycle",
severity="info",
title="started",
summary="x",
details={"lifecycle": "started", "telegram_via": "agent"},
payload={"source": {"product": "rdp-login-monitor", "product_version": "1.2.30-SAC"}},
)
text = format_event_telegram_html(event)
assert "📡 Оповещение: агент (rdp-login-monitor 1.2.30-SAC)" in text
def test_daily_report_summary_fallback_includes_host():
host = Host(hostname="srv1", display_name="K6A-DC5", ipv4="192.168.160.91", os_family="linux")
event = Event(
event_id="00000000-0000-4000-8000-000000000508",
host_id=1,
host=host,
occurred_at=datetime(2026, 6, 3, 9, 0, tzinfo=timezone.utc),
category="report",
type="report.daily.ssh",
severity="info",
title="Ежедневный отчёт SSH",
summary="SSH 24ч: успех 0, неудач 0, банов 0",
details={"generated_by": "sac"},
payload={},
)
text = format_event_telegram_html(event)
assert "K6A-DC5" in text
assert "SSH 24ч: успех 0" in text
assert "ЕЖЕДНЕВНЫЙ ОТЧЕТ SSH" in text
def test_rdp_shadow_control_template():
host = Host(hostname="RDS01", display_name="RDS Farm", ipv4="10.0.0.5", os_family="windows")
event = Event(
event_id="00000000-0000-4000-8000-000000000701",
host_id=1,
host=host,
occurred_at=datetime(2026, 5, 30, 10, 0, 0, tzinfo=timezone.utc),
category="session",
type="rdp.shadow.control.started",
severity="warning",
title="RDS Shadow Control 20506",
summary="Shadow admin -> user",
details={
"event_id_windows": 20506,
"shadower_user": "DOMAIN\\admin",
"target_user": "DOMAIN\\user",
"session_id": "3",
"shadow_action": "control_started",
},
payload={},
)
text = format_event_telegram_html(event)
assert "SHADOW CONTROL" in text
assert "DOMAIN" in text
assert "20506" in text
assert "RDS Farm" in text
def test_winrm_session_template():
host = Host(hostname="SRV01", display_name="App Server", os_family="windows")
event = Event(
event_id="00000000-0000-4000-8000-000000000702",
host_id=1,
host=host,
occurred_at=datetime(2026, 5, 30, 11, 0, 0, tzinfo=timezone.utc),
category="session",
type="winrm.session.started",
severity="warning",
title="WinRM shell",
summary="WinRM 91",
details={
"event_id_windows": 91,
"user": "DOMAIN\\admin",
"source_ip": "192.168.160.3",
"resource_uri": "http://schemas.microsoft.com/powershell/Microsoft.PowerShell",
},
payload={},
)
text = format_event_telegram_html(event)
assert "Enter-PSSession" in text or "WinRM" in text
assert "192.168.160.3" in text
assert "91" in text
def test_ssh_failed_template():
event = Event(
event_id="00000000-0000-4000-8000-000000000503",
host_id=1,
occurred_at=datetime(2026, 5, 29, 12, 0, tzinfo=timezone.utc),
category="auth",
type="ssh.login.failed",
severity="warning",
title="ssh fail",
summary="fail",
details={"user": "root", "source_ip": "10.10.36.9", "port": 22, "attempt_number": 3, "max_attempts": 5},
payload={},
)
text = format_event_telegram_html(event)
assert "SSH" in text
assert "root" in text
assert "10.10.36.9" in text
assert "3 / 5" in text
def test_agent_inventory_hardware_change_template():
event = Event(
event_id="00000000-0000-4000-8000-000000000504",
host_id=1,
occurred_at=datetime(2026, 6, 4, 12, 0, tzinfo=timezone.utc),
category="agent",
type="agent.inventory",
severity="warning",
title="Hardware changed",
summary="memory_gb changed",
details={
"hardware_changes": [
{"field": "memory_gb", "old": 16.0, "new": 32.0},
]
},
payload={},
)
text = format_event_telegram_html(event)
assert "железо" in text.lower() or "Изменилось" in text
assert "memory_gb" in text
assert "32" in text
def test_format_problem_host_silence_scan_without_event():
from app.models import Host, Problem
from app.services.problem_rules import RULE_HOST_SILENCE
from app.services.telegram_templates import format_problem_telegram_html
host = Host(
hostname="YURKOV-PC",
os_family="windows",
product="rdp-login-monitor",
last_seen_at=datetime(2026, 6, 11, 9, 0, tzinfo=timezone.utc),
)
problem = Problem(
host_id=1,
host=host,
title="Host silence: YURKOV-PC",
summary="Нет свежего agent.heartbeat более 300 мин",
severity="high",
status="open",
rule_id=RULE_HOST_SILENCE,
fingerprint="fp-silence",
event_count=0,
last_seen_at=datetime(2026, 6, 11, 9, 0, tzinfo=timezone.utc),
)
text = format_problem_telegram_html(problem, event=None)
assert "периодическая проверка SAC" in text
assert "host_silence scan" in text
assert "YURKOV-PC" in text or "UNMS" not in text # host label uses hostname
+32
View File
@@ -0,0 +1,32 @@
"""GET/PUT /api/v1/settings/ui"""
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
def test_get_ui_settings_default(jwt_headers, client):
response = client.get("/api/v1/settings/ui", headers=jwt_headers)
assert response.status_code == 200
body = response.json()
assert body["show_sidebar_system_stats"] is True
assert body["source"] == "default"
def test_put_ui_settings_persists(jwt_headers, client, db_session):
response = client.put(
"/api/v1/settings/ui",
headers=jwt_headers,
json={"show_sidebar_system_stats": False},
)
assert response.status_code == 200
body = response.json()
assert body["show_sidebar_system_stats"] is False
assert body["source"] == "db"
row = db_session.get(UiSettings, UI_SETTINGS_ROW_ID)
assert row is not None
assert row.show_sidebar_system_stats is False
def test_ui_settings_requires_admin(jwt_monitor_headers, client):
response = client.get("/api/v1/settings/ui", headers=jwt_monitor_headers)
assert response.status_code == 403
+136
View File
@@ -0,0 +1,136 @@
"""Multi-user UI auth and RBAC."""
from app.models.user import USER_ROLE_MONITOR
def test_login_success(client):
response = client.post(
"/api/v1/auth/login",
json={"username": "test-admin", "password": "test-admin-password"},
)
assert response.status_code == 200
body = response.json()
assert body["username"] == "test-admin"
assert body["role"] == "admin"
assert body["access_token"]
def test_login_monitor(client):
response = client.post(
"/api/v1/auth/login",
json={"username": "test-monitor", "password": "test-monitor-password"},
)
assert response.status_code == 200
assert response.json()["role"] == USER_ROLE_MONITOR
def test_login_invalid_password(client):
response = client.post(
"/api/v1/auth/login",
json={"username": "test-admin", "password": "wrong"},
)
assert response.status_code == 401
def test_auth_me(client, jwt_headers):
response = client.get("/api/v1/auth/me", headers=jwt_headers)
assert response.status_code == 200
assert response.json() == {"username": "test-admin", "role": "admin"}
def test_settings_forbidden_for_monitor(client, jwt_monitor_headers):
response = client.get("/api/v1/settings/notifications", headers=jwt_monitor_headers)
assert response.status_code == 403
def test_events_allowed_for_monitor(client, jwt_monitor_headers):
response = client.get("/api/v1/events", headers=jwt_monitor_headers)
assert response.status_code == 200
def test_admin_create_monitor_user(client, jwt_headers):
response = client.post(
"/api/v1/users",
headers=jwt_headers,
json={"username": "new-monitor", "password": "longpassword1", "role": "monitor"},
)
assert response.status_code == 201
assert response.json()["username"] == "new-monitor"
assert response.json()["role"] == "monitor"
def test_monitor_cannot_list_users(client, jwt_monitor_headers):
response = client.get("/api/v1/users", headers=jwt_monitor_headers)
assert response.status_code == 403
def test_admin_update_user_role_and_password(client, jwt_headers):
create = client.post(
"/api/v1/users",
headers=jwt_headers,
json={"username": "patch-me", "password": "longpassword1", "role": "monitor"},
)
assert create.status_code == 201
user_id = create.json()["id"]
response = client.patch(
f"/api/v1/users/{user_id}",
headers=jwt_headers,
json={"role": "admin", "password": "newpassword9"},
)
assert response.status_code == 200
body = response.json()
assert body["role"] == "admin"
login = client.post(
"/api/v1/auth/login",
json={"username": "patch-me", "password": "newpassword9"},
)
assert login.status_code == 200
assert login.json()["role"] == "admin"
def test_admin_update_username(client, jwt_headers):
create = client.post(
"/api/v1/users",
headers=jwt_headers,
json={"username": "old-name", "password": "longpassword1", "role": "monitor"},
)
user_id = create.json()["id"]
response = client.patch(
f"/api/v1/users/{user_id}",
headers=jwt_headers,
json={"username": "new-name"},
)
assert response.status_code == 200
assert response.json()["username"] == "new-name"
login = client.post(
"/api/v1/auth/login",
json={"username": "new-name", "password": "longpassword1"},
)
assert login.status_code == 200
def test_admin_deactivate_user(client, jwt_headers):
create = client.post(
"/api/v1/users",
headers=jwt_headers,
json={"username": "to-disable", "password": "longpassword1", "role": "monitor"},
)
user_id = create.json()["id"]
response = client.patch(
f"/api/v1/users/{user_id}",
headers=jwt_headers,
json={"is_active": False},
)
assert response.status_code == 200
assert response.json()["is_active"] is False
login = client.post(
"/api/v1/auth/login",
json={"username": "to-disable", "password": "longpassword1"},
)
assert login.status_code == 401
+55
View File
@@ -0,0 +1,55 @@
"""Tests for SAC webhook notification helpers."""
from datetime import datetime, timezone
from unittest.mock import patch
from app.models import Event
from app.services import webhook_notify
from app.services.notification_settings import WebhookConfig
def test_notify_event_respects_min_severity():
sent: list[dict] = []
def fake_send(payload: dict, **kwargs) -> None:
sent.append(payload)
event = Event(
event_id="00000000-0000-4000-8000-000000000201",
host_id=1,
occurred_at=datetime(2026, 5, 29, 12, 0, tzinfo=timezone.utc),
category="auth",
type="rdp.login.failed",
severity="warning",
title="failed",
summary="e2e",
payload={},
)
cfg = WebhookConfig(
enabled=True,
url="https://example.com/hook",
secret_header="",
secret="",
min_severity="high",
source="env",
)
with patch.object(webhook_notify, "get_effective_webhook_config", return_value=cfg):
with patch.object(webhook_notify, "send_webhook_payload", side_effect=fake_send):
webhook_notify.notify_event(event)
assert sent == []
cfg_high = WebhookConfig(
enabled=True,
url="https://example.com/hook",
secret_header="",
secret="",
min_severity="warning",
source="env",
)
event.severity = "warning"
with patch.object(webhook_notify, "get_effective_webhook_config", return_value=cfg_high):
with patch.object(webhook_notify, "send_webhook_payload", side_effect=fake_send):
webhook_notify.notify_event(event)
assert len(sent) == 1
assert sent[0]["kind"] == "event"
+115
View File
@@ -0,0 +1,115 @@
"""Win admin settings and WinRM host test."""
from unittest.mock import patch
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
from app.services.win_admin_settings import normalize_win_admin_user
from app.services.winrm_connect import iter_winrm_targets
def test_normalize_win_admin_user():
assert normalize_win_admin_user(r"B26\\papatramp") == r"B26\papatramp"
assert normalize_win_admin_user(r"B26\papatramp") == r"B26\papatramp"
def test_iter_winrm_targets_hostname_before_ip():
from app.models import Host
host = Host(
hostname="Andrisonova-PC",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.1.50",
inventory={"computer_name": "Andrisonova-PC"},
)
targets = iter_winrm_targets(host)
assert targets[0] == "Andrisonova-PC"
assert targets[-1] == "192.168.1.50"
def test_get_win_admin_settings_env_default(jwt_headers, client, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", "")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "")
from app.config import get_settings
get_settings.cache_clear()
response = client.get("/api/v1/settings/win-admin", headers=jwt_headers)
assert response.status_code == 200
body = response.json()
assert body["configured"] is False
assert body["source"] == "env"
def test_put_win_admin_settings_persists(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", "")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "")
from app.config import get_settings
get_settings.cache_clear()
response = client.put(
"/api/v1/settings/win-admin",
headers=jwt_headers,
json={"user": r"B26\papatramp", "password": "secret-pass"},
)
assert response.status_code == 200
body = response.json()
assert body["configured"] is True
assert body["user"] == r"B26\papatramp"
assert body["source"] == "db"
assert body["password_hint"]
row = db_session.get(UiSettings, UI_SETTINGS_ROW_ID)
assert row is not None
assert row.win_admin_user == r"B26\papatramp"
assert row.win_admin_password == "secret-pass"
def test_host_winrm_test_requires_admin(jwt_monitor_headers, client):
response = client.post("/api/v1/hosts/1/actions/winrm-test", headers=jwt_monitor_headers)
assert response.status_code == 403
def test_host_winrm_test_not_found(jwt_headers, client):
response = client.post("/api/v1/hosts/99999/actions/winrm-test", headers=jwt_headers)
assert response.status_code == 404
def test_host_winrm_test_success(jwt_headers, client, db_session, monkeypatch):
monkeypatch.setenv("SAC_WIN_ADMIN_USER", r"B26\admin")
monkeypatch.setenv("SAC_WIN_ADMIN_PASSWORD", "pw")
from app.config import get_settings
get_settings.cache_clear()
from app.models import Host
from app.services.winrm_connect import WinRmTestResult
host = Host(
hostname="PC01",
os_family="windows",
product="rdp-login-monitor",
ipv4="192.168.1.10",
)
db_session.add(host)
db_session.commit()
db_session.refresh(host)
with patch("app.api.v1.hosts.test_winrm_connection") as mock_test:
mock_test.return_value = WinRmTestResult(
ok=True,
message="WinRM OK, hostname=PC01",
target="192.168.1.10",
hostname="PC01",
)
response = client.post(
f"/api/v1/hosts/{host.id}/actions/winrm-test",
headers=jwt_headers,
)
assert response.status_code == 200
body = response.json()
assert body["ok"] is True
assert body["hostname"] == "PC01"
assert body["target"] == "192.168.1.10"
+170
View File
@@ -0,0 +1,170 @@
"""WinRM bundle delivery and CLIXML error parsing tests."""
from pathlib import Path
from unittest.mock import patch
from app.services.rdp_bundle_delivery import (
_UTF8_BOM,
build_rdp_bundle_zip,
get_rdp_bundle_zip,
register_rdp_bundle_zip,
)
from app.services.winrm_connect import (
RDP_BUNDLE_REQUIRED,
RDP_LEGACY_STAGING,
RDP_REMOTE_STAGING_DIRNAME,
WinRmCmdResult,
_clixml_to_plain,
_custom_deploy_body,
_decode_winrm_bytes,
_deploy_from_staging_body,
_download_bundle_body,
_prepare_staging_body,
_winrm_failure_detail,
run_winrm_rdp_monitor_update,
)
def test_decode_winrm_bytes_reads_russian_cp866_console():
sample = "Деплой: корень шары".encode("cp866")
decoded = _decode_winrm_bytes(sample)
assert "Деплой" in decoded
assert "шары" in decoded
def test_decode_winrm_bytes_handles_utf8_multibyte():
text = "\u0414\u0435\u043f\u043b\u043e\u0439: \u043a\u043e\u0440\u0435\u043d\u044c \u0448\u0430\u0440\u044b"
assert _decode_winrm_bytes(text.encode("utf-8")) == text
def test_deploy_from_staging_includes_deploy_log_tail():
script = _deploy_from_staging_body()
assert "deploy.log" in script
assert "Get-Content" in script
assert "-Encoding UTF8" in script
assert RDP_REMOTE_STAGING_DIRNAME in script
def test_prepare_staging_recreates_remote_dir():
script = _prepare_staging_body()
assert "Remove-Item" in script
assert RDP_REMOTE_STAGING_DIRNAME in script
assert RDP_LEGACY_STAGING in script
def test_download_bundle_uses_invoke_webrequest():
script = _download_bundle_body(
"https://sac.example/api/v1/agent/rdp-bundle/token",
)
assert "Invoke-WebRequest" in script
assert "$env:TEMP" in script
assert RDP_REMOTE_STAGING_DIRNAME in script
assert "ZipFile]::ExtractToDirectory" in script
assert "_sac_staging" not in script or RDP_LEGACY_STAGING in script
assert "Expand-Archive" not in script
def test_winrm_failure_detail_prefers_error_line_over_progress_stdout():
stdout = "Downloading bundle: https://sac.test/bundle\nERROR: destination not empty"
detail = _winrm_failure_detail(stdout, "", 1)
assert detail == "destination not empty"
def test_deploy_from_staging_runs_local_bundle():
script = _deploy_from_staging_body()
assert "-SourceShareRoot" in script
assert "Deploy-LoginMonitor.ps1" in script
def test_custom_deploy_script_escapes_single_quotes():
script = _custom_deploy_body(r"C:\Users\O'Brien\Deploy-LoginMonitor.ps1")
assert "O''Brien" in script
def test_rdp_bundle_zip_adds_utf8_bom_for_ps1(tmp_path: Path, monkeypatch):
monkeypatch.setattr("app.services.rdp_bundle_delivery.BUNDLE_DIR", tmp_path)
repo = tmp_path / "repo"
repo.mkdir()
(repo / "Deploy-LoginMonitor.ps1").write_text("# test", encoding="utf-8")
zip_bytes = build_rdp_bundle_zip(repo, ("Deploy-LoginMonitor.ps1",))
import zipfile
import io
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive:
data = archive.read("Deploy-LoginMonitor.ps1")
assert data.startswith(_UTF8_BOM)
def test_rdp_bundle_zip_roundtrip(tmp_path: Path, monkeypatch):
monkeypatch.setattr("app.services.rdp_bundle_delivery.BUNDLE_DIR", tmp_path)
repo = tmp_path / "repo"
repo.mkdir()
for name in RDP_BUNDLE_REQUIRED:
(repo / name).write_text(name, encoding="utf-8")
zip_bytes = build_rdp_bundle_zip(repo, tuple(RDP_BUNDLE_REQUIRED))
token = register_rdp_bundle_zip(zip_bytes)
assert get_rdp_bundle_zip(token) == zip_bytes
def test_resolve_winrm_cmd_uses_system32_for_builtins():
from app.services.winrm_connect import _resolve_winrm_cmd
assert _resolve_winrm_cmd("hostname") == "%SystemRoot%\\System32\\hostname.exe"
assert _resolve_winrm_cmd("qwinsta") == "%SystemRoot%\\System32\\qwinsta.exe"
assert _resolve_winrm_cmd("logoff 2 /v") == "%SystemRoot%\\System32\\logoff.exe 2 /v"
def test_run_winrm_rdp_monitor_update_downloads_bundle_from_sac(tmp_path: Path):
repo = tmp_path / "repo"
repo.mkdir()
for name in RDP_BUNDLE_REQUIRED:
(repo / name).write_text(f"content-{name}", encoding="utf-8")
calls: list[str] = []
def fake_run_ps(*, script: str, **kwargs) -> WinRmCmdResult:
calls.append(script)
if "Remove-Item" in script:
return WinRmCmdResult(ok=True, message="staging ok", target="pc", stdout="Staging ready")
if "Invoke-WebRequest" in script:
return WinRmCmdResult(ok=True, message="download ok", target="pc", stdout="Bundle extracted")
if "powershell.exe -NoProfile" in script or "& $deploy" in script:
return WinRmCmdResult(
ok=True,
message="WinRM OK (pc), exit 0",
target="pc",
stdout="deployed 1.2.3",
exit_code=0,
)
return WinRmCmdResult(ok=True, message="ok", target="pc", exit_code=0)
with (
patch("app.services.winrm_connect._fetch_rdp_bundle_dir", return_value=repo),
patch("app.services.winrm_connect._bundle_download_url", return_value="https://sac.test/bundle.zip"),
patch("app.services.winrm_connect.run_winrm_ps", side_effect=fake_run_ps),
):
result = run_winrm_rdp_monitor_update(
target="pc",
user="B26\\admin",
password="pw",
repo_url="https://git.example.com/RDP-login-monitor.git",
)
assert result.ok is True
assert "SAC served bundle" in result.stdout
assert any("Invoke-WebRequest" in call for call in calls)
assert not any("FromBase64String" in call for call in calls)
def test_winrm_failure_detail_never_returns_raw_clixml_progress():
clixml = (
"#< CLIXML <Objs Version=\"1.1.0.1\">"
'<Obj S="progress"><MS><PR N="Record"><AV>Preparing modules for first use.</AV></PR></MS></Obj>'
)
detail = _winrm_failure_detail("", clixml, 1)
assert "#< CLIXML" not in detail
def test_clixml_to_plain_strips_progress_only_blob():
clixml = "#< CLIXML <Objs><Obj S=\"progress\"><AV>Preparing modules for first use.</AV></Obj></Objs>"
assert _clixml_to_plain(clixml) == ""