fix: test push errors, skip heartbeat notify, stale 5h (0.9.2)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -83,7 +83,7 @@ class Settings(BaseSettings):
|
||||
sac_daily_report_require_activity: bool = True
|
||||
|
||||
# Порог «живости» агента
|
||||
sac_heartbeat_stale_minutes: int = 780
|
||||
sac_heartbeat_stale_minutes: int = 300
|
||||
|
||||
# Периодическое сканирование stale heartbeat (proactive host_silence)
|
||||
sac_host_silence_scan_enabled: bool = True
|
||||
|
||||
@@ -98,11 +98,22 @@ def _active_device_tokens(db: Session | None) -> list[str]:
|
||||
return tokens
|
||||
|
||||
|
||||
def _send_fcm_data(tokens: list[str], data: dict[str, str], *, title: str, body: str) -> None:
|
||||
def _send_fcm_data(
|
||||
tokens: list[str],
|
||||
data: dict[str, str],
|
||||
*,
|
||||
title: str,
|
||||
body: str,
|
||||
require_success: bool = False,
|
||||
) -> None:
|
||||
if not tokens:
|
||||
if require_success:
|
||||
raise MobileSendError("Нет FCM-токенов для отправки")
|
||||
return
|
||||
cfg = get_effective_fcm_config()
|
||||
if not cfg.active:
|
||||
if require_success:
|
||||
raise MobileNotConfiguredError("FCM не настроен на сервере")
|
||||
return
|
||||
|
||||
access_token = _fcm_access_token(cfg.service_account_path)
|
||||
@@ -111,6 +122,7 @@ def _send_fcm_data(tokens: list[str], data: dict[str, str], *, title: str, body:
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
errors: list[str] = []
|
||||
|
||||
with httpx.Client(timeout=12.0) as client:
|
||||
for token in tokens:
|
||||
@@ -128,8 +140,13 @@ def _send_fcm_data(tokens: list[str], data: dict[str, str], *, title: str, body:
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = exc.response.text[:200] if exc.response is not None else str(exc)
|
||||
logger.warning("FCM send failed for token prefix %s: %s", token[:8], detail)
|
||||
except Exception:
|
||||
errors.append(detail or f"HTTP {exc.response.status_code if exc.response else '?'}")
|
||||
except Exception as exc:
|
||||
logger.exception("FCM send failed")
|
||||
errors.append(str(exc))
|
||||
|
||||
if require_success and errors:
|
||||
raise MobileSendError(errors[0], status_code=502)
|
||||
|
||||
|
||||
def _event_payload(event: Event) -> tuple[str, str, dict[str, str]]:
|
||||
@@ -200,7 +217,8 @@ def send_test_push(*, db: Session, device_id: int) -> None:
|
||||
|
||||
_send_fcm_data(
|
||||
[token],
|
||||
{"kind": "test", "id": "0"},
|
||||
{"kind": "test", "id": "0", "severity": "info"},
|
||||
title="SAC: тестовое push",
|
||||
body="Канал Seaca (FCM) работает.",
|
||||
require_success=True,
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.models import Event, Problem
|
||||
from app.services import email_notify, mobile_notify, telegram_notify, webhook_notify
|
||||
from app.services.notification_cooldown import should_notify_event, should_notify_problem
|
||||
from app.services.notification_policy import get_effective_notification_policy
|
||||
from app.services.host_health import HEARTBEAT_TYPE
|
||||
from app.services.notification_severity import severity_meets_minimum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -51,6 +52,9 @@ def _dispatch_problem_channels(problem: Problem, event: Event | None, *, db: Ses
|
||||
|
||||
|
||||
def notify_event(event: Event, *, db: Session | None = None) -> None:
|
||||
# Heartbeat — только для UI/статуса хоста, не для Telegram/email/push.
|
||||
if event.type == HEARTBEAT_TYPE:
|
||||
return
|
||||
policy = get_effective_notification_policy(db)
|
||||
if not severity_meets_minimum(event.severity, policy.min_severity):
|
||||
return
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
|
||||
|
||||
APP_NAME = "Security Alert Center"
|
||||
APP_VERSION = "0.9.1"
|
||||
APP_VERSION = "0.9.2"
|
||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||
|
||||
@@ -64,6 +64,38 @@ def test_notify_event_calls_selected_channels():
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user