chore(home): mirror from kalinamall (9883e6a) with papatramp URLs
This commit is contained in:
@@ -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}"}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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() == []
|
||||
@@ -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:")
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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<b>&"
|
||||
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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) == ""
|
||||
Reference in New Issue
Block a user