522 lines
16 KiB
Python
522 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
from contextlib import asynccontextmanager
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
import src.db.base as db_base
|
|
import src.services.approval_db as approval_db
|
|
import src.services.telegram_gateway as gateway_module
|
|
from src.services.telegram_gateway import TelegramGateway
|
|
|
|
|
|
class _FakeResponse:
|
|
def raise_for_status(self) -> None:
|
|
return None
|
|
|
|
def json(self) -> dict:
|
|
return {"ok": True, "result": {"message_id": 11}}
|
|
|
|
|
|
class _FakeHttpClient:
|
|
def __init__(self) -> None:
|
|
self.posts: list[tuple[str, dict]] = []
|
|
|
|
async def post(self, url: str, json: dict) -> _FakeResponse:
|
|
self.posts.append((url, json))
|
|
return _FakeResponse()
|
|
|
|
|
|
def _prepared_gateway(monkeypatch: pytest.MonkeyPatch) -> tuple[TelegramGateway, _FakeHttpClient]:
|
|
gateway = TelegramGateway()
|
|
client = _FakeHttpClient()
|
|
gateway._initialized = True
|
|
gateway._http_client = client # type: ignore[assignment]
|
|
monkeypatch.setattr(gateway_module.settings, "SRE_GROUP_CHAT_ID", "test-room")
|
|
monkeypatch.setattr(gateway, "_attach_incident_thread_reply", AsyncMock())
|
|
monkeypatch.setattr(gateway, "_mirror_outbound_message", AsyncMock())
|
|
monkeypatch.setattr(
|
|
gateway._security,
|
|
"generate_callback_nonce",
|
|
lambda approval_id, action: f"{action}:test",
|
|
)
|
|
return gateway, client
|
|
|
|
|
|
def _notification_message():
|
|
from src.services.notifications.base import ExecutionStatus, NotificationMessage
|
|
|
|
return NotificationMessage(
|
|
execution_status=ExecutionStatus.SUCCESS,
|
|
action_title="bounded test",
|
|
action_description="no provider egress",
|
|
approval_id="APR-TEST",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notification_provider_does_not_report_success_for_gateway_no_send(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from src.services.notifications import telegram as provider_module
|
|
from src.services.notifications.base import NotificationStatus
|
|
|
|
monkeypatch.setattr(provider_module.settings, "OPENCLAW_TG_BOT_TOKEN", "configured")
|
|
monkeypatch.setattr(provider_module.settings, "SRE_GROUP_CHAT_ID", "test-room")
|
|
gateway = SimpleNamespace(
|
|
send_alert_notification=AsyncMock(
|
|
return_value={
|
|
"ok": False,
|
|
"_awooop_delivery_status": "blocked_no_egress",
|
|
"_awooop_provider_send_performed": False,
|
|
}
|
|
)
|
|
)
|
|
monkeypatch.setattr(gateway_module, "get_telegram_gateway", lambda: gateway)
|
|
|
|
result = await provider_module.TelegramWebhookProvider().send(
|
|
_notification_message()
|
|
)
|
|
|
|
assert result.status is NotificationStatus.SKIPPED
|
|
assert result.message == "Telegram notification blocked before provider send"
|
|
assert result.response_data == {
|
|
"ok": False,
|
|
"_awooop_delivery_status": "blocked_no_egress",
|
|
"_awooop_provider_send_performed": False,
|
|
}
|
|
gateway.send_alert_notification.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notification_provider_requires_affirmative_delivery_ack(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from src.services.notifications import telegram as provider_module
|
|
from src.services.notifications.base import NotificationStatus
|
|
|
|
monkeypatch.setattr(provider_module.settings, "OPENCLAW_TG_BOT_TOKEN", "configured")
|
|
monkeypatch.setattr(provider_module.settings, "SRE_GROUP_CHAT_ID", "test-room")
|
|
gateway = SimpleNamespace(
|
|
send_alert_notification=AsyncMock(
|
|
return_value={"ok": False, "description": "provider rejected"}
|
|
)
|
|
)
|
|
monkeypatch.setattr(gateway_module, "get_telegram_gateway", lambda: gateway)
|
|
|
|
result = await provider_module.TelegramWebhookProvider().send(
|
|
_notification_message()
|
|
)
|
|
|
|
assert result.status is NotificationStatus.FAILED
|
|
assert result.message == "Telegram provider did not acknowledge delivery"
|
|
assert result.error == "delivery_not_acknowledged"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unclassified_send_message_is_blocked_before_provider(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
gateway, client = _prepared_gateway(monkeypatch)
|
|
|
|
result = await gateway._send_request(
|
|
"sendMessage",
|
|
{"chat_id": "test-room", "text": "unclassified"},
|
|
)
|
|
|
|
assert result["_awooop_delivery_status"] == "blocked_no_egress"
|
|
assert result["_awooop_provider_send_performed"] is False
|
|
assert client.posts == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_info_and_business_methods_are_blocked_before_provider(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
gateway, client = _prepared_gateway(monkeypatch)
|
|
|
|
info = await gateway.send_info_notification(
|
|
incident_id="INC-TEST",
|
|
title="FYI",
|
|
message="healthy",
|
|
)
|
|
business = await gateway.send_business_alert(
|
|
incident_id="INC-TEST",
|
|
alertname="BusinessMetric",
|
|
business_domain="test",
|
|
metric_name="rate",
|
|
current_value="1",
|
|
threshold="2",
|
|
)
|
|
|
|
assert info["_awooop_delivery_status"] == "blocked_no_egress"
|
|
assert business["_awooop_delivery_status"] == "blocked_no_egress"
|
|
assert client.posts == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wrong_group_override_is_blocked_before_provider(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
gateway, client = _prepared_gateway(monkeypatch)
|
|
|
|
result = await gateway.send_escalation_card(
|
|
incident_id="INC-TEST",
|
|
original_alertname="Outage",
|
|
duration_min=15,
|
|
group_chat_id="wrong-room",
|
|
)
|
|
|
|
assert result["_awooop_delivery_status"] == "blocked_no_egress"
|
|
assert result["_awoooi_canonical_route_receipt"]["classifier"] == (
|
|
"caller_destination_mismatch"
|
|
)
|
|
assert client.posts == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("method_name", ["meta", "secops", "escalation"])
|
|
async def test_allowed_legacy_monitoring_methods_use_canonical_provider_once(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
method_name: str,
|
|
) -> None:
|
|
gateway, client = _prepared_gateway(monkeypatch)
|
|
|
|
if method_name == "meta":
|
|
result = await gateway.send_meta_alert(
|
|
incident_id="INC-TEST",
|
|
approval_id="APR-TEST",
|
|
alertname="AlertChainBroken",
|
|
alert_category="alertchain_health",
|
|
diagnosis="test",
|
|
)
|
|
elif method_name == "secops":
|
|
result = await gateway.send_secops_card(
|
|
incident_id="INC-TEST",
|
|
approval_id="APR-TEST",
|
|
alertname="Threat",
|
|
threat_level="critical",
|
|
)
|
|
else:
|
|
result = await gateway.send_escalation_card(
|
|
incident_id="INC-TEST",
|
|
original_alertname="Outage",
|
|
duration_min=15,
|
|
)
|
|
|
|
assert result["ok"] is True
|
|
assert result["_awoooi_canonical_route_receipt"]["decision"] == "allowed"
|
|
assert result["_awoooi_canonical_route_receipt"]["provider_send_performed"] is True
|
|
assert len(client.posts) == 1
|
|
assert client.posts[0][1]["chat_id"] == "test-room"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openclaw_cannot_own_tsenyang_monitoring_route(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
gateway, client = _prepared_gateway(monkeypatch)
|
|
|
|
result = await gateway._send_as_bot(
|
|
token="test-token-placeholder",
|
|
text="must not send",
|
|
product_id="awoooi",
|
|
signal_family="security_incident",
|
|
severity="P1",
|
|
actual_bot_alias="openclaw_bot",
|
|
)
|
|
|
|
assert result["_awooop_delivery_status"] == "blocked_no_egress"
|
|
assert result["_awoooi_canonical_route_receipt"]["classifier"] == (
|
|
"sender_bot_alias_not_authorized_for_route"
|
|
)
|
|
assert client.posts == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_operator_reply_lane_and_runtime_edit_binding_remain_available(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
gateway, client = _prepared_gateway(monkeypatch)
|
|
|
|
assert gateway.chat_id == "test-room"
|
|
assert gateway.alert_chat_id == "test-room"
|
|
unverified = await gateway.send_notification(
|
|
"must not infer an operator reply",
|
|
chat_id="operator-room",
|
|
)
|
|
|
|
assert unverified["_awooop_delivery_status"] == "blocked_no_egress"
|
|
assert unverified["_awoooi_canonical_route_receipt"]["classifier"] == (
|
|
"missing_product_signal_severity_or_interaction_context"
|
|
)
|
|
assert client.posts == []
|
|
|
|
result = await gateway.send_notification(
|
|
"command reply",
|
|
chat_id="operator-room",
|
|
interaction_kind="operator_command_reply",
|
|
inbound_chat_id="operator-room",
|
|
inbound_message_id=42,
|
|
)
|
|
|
|
assert result["ok"] is True
|
|
receipt = result["_awoooi_canonical_route_receipt"]
|
|
assert receipt["classifier"] == "non_monitoring_interaction_allowed"
|
|
assert receipt["destination_alias"] == "inbound_interaction_reply_target"
|
|
assert client.posts[0][1]["chat_id"] == "operator-room"
|
|
assert client.posts[0][1]["reply_to_message_id"] == 42
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interaction_reply_must_match_verified_inbound_context(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
gateway, client = _prepared_gateway(monkeypatch)
|
|
|
|
result = await gateway._send_request(
|
|
"sendMessage",
|
|
{
|
|
"chat_id": "operator-room",
|
|
"text": "mismatched reply",
|
|
"reply_to_message_id": 43,
|
|
gateway_module._NON_MONITORING_INTERACTION_KEY: {
|
|
"interaction_kind": "operator_command_reply",
|
|
"inbound_chat_id": "operator-room",
|
|
"inbound_message_id": 42,
|
|
},
|
|
},
|
|
)
|
|
|
|
assert result["_awooop_delivery_status"] == "blocked_no_egress"
|
|
assert result["_awoooi_canonical_route_receipt"]["classifier"] == (
|
|
"interaction_reply_context_missing_or_mismatched"
|
|
)
|
|
assert client.posts == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_t1_through_t6_use_only_canonical_monitoring_routes(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
gateway = TelegramGateway()
|
|
canonical_send = AsyncMock(return_value={"ok": True})
|
|
monkeypatch.setattr(gateway, "send_canonical_message", canonical_send)
|
|
|
|
await gateway.send_guardrail_blocked("api", "HighCpu", "protected")
|
|
await gateway.send_preflight_failed("api", 25.0, 24.0, "backup-1")
|
|
await gateway.send_backup_result("backup-1", True)
|
|
await gateway.send_multisig_waiting("repair", "api", 1, 2, "APR-1")
|
|
await gateway.send_multisig_approved("repair", "api")
|
|
await gateway.send_change_applied("operator", "repair", "2026-07-14T00:00:00Z")
|
|
|
|
assert canonical_send.await_count == 6
|
|
assert [
|
|
(
|
|
call.kwargs["product_id"],
|
|
call.kwargs["signal_family"],
|
|
call.kwargs["severity"],
|
|
)
|
|
for call in canonical_send.await_args_list
|
|
] == [
|
|
("awoooi", "security_incident", "P1"),
|
|
("awoooi", "disaster_recovery", "P1"),
|
|
("awoooi", "disaster_recovery", "P1"),
|
|
("awoooi", "incident_lifecycle", "P1"),
|
|
("awoooi", "incident_lifecycle", "P1"),
|
|
("awoooi", "incident_lifecycle", "P1"),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_image_analysis_reply_preserves_original_inbound_message_context(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
from src.services.image_analysis_service import ImageAnalysisService
|
|
|
|
gateway = SimpleNamespace(
|
|
initialize=AsyncMock(),
|
|
send_as_openclaw=AsyncMock(return_value={"ok": True}),
|
|
)
|
|
service = ImageAnalysisService()
|
|
monkeypatch.setattr(
|
|
gateway_module,
|
|
"get_telegram_gateway",
|
|
lambda: gateway,
|
|
)
|
|
monkeypatch.setattr(
|
|
service,
|
|
"_download_telegram_file",
|
|
AsyncMock(return_value=b"image"),
|
|
)
|
|
monkeypatch.setattr(
|
|
service,
|
|
"analyze",
|
|
AsyncMock(return_value="analysis"),
|
|
)
|
|
|
|
await service.download_and_analyze(
|
|
chat_id="test-room",
|
|
file_id="file-1",
|
|
original_message_id=77,
|
|
)
|
|
|
|
gateway.initialize.assert_awaited_once()
|
|
gateway.send_as_openclaw.assert_awaited_once_with(
|
|
"🖼️ <b>圖片分析</b>\nanalysis",
|
|
reply_to_message_id=77,
|
|
inbound_chat_id="test-room",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manual_repair_callback_replies_to_verified_original_message(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
gateway, client = _prepared_gateway(monkeypatch)
|
|
monkeypatch.setattr(
|
|
gateway._security,
|
|
"parse_callback_data",
|
|
lambda _data: {
|
|
"action": "log_manual_fix",
|
|
"approval_id": "APR-1",
|
|
"is_info_action": False,
|
|
"nonce": "nonce-1",
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
gateway._security,
|
|
"verify_callback",
|
|
AsyncMock(return_value={"id": 123, "username": "operator"}),
|
|
)
|
|
monkeypatch.setattr(
|
|
gateway,
|
|
"_check_incident_state_guard",
|
|
AsyncMock(return_value=None),
|
|
)
|
|
monkeypatch.setattr(gateway, "_answer_callback", AsyncMock())
|
|
monkeypatch.setattr(gateway_module, "get_redis", lambda: _FakeRedis())
|
|
|
|
result = await gateway.handle_callback(
|
|
callback_query_id="callback-1",
|
|
callback_data="log_manual_fix:APR-1:nonce-1",
|
|
user_id=123,
|
|
message_id=77,
|
|
chat_id="operator-room",
|
|
username="operator",
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["waiting_for_manual_fix"] is True
|
|
assert len(client.posts) == 1
|
|
payload = client.posts[0][1]
|
|
assert payload["chat_id"] == "operator-room"
|
|
assert payload["reply_to_message_id"] == 77
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manual_repair_flow_blocks_before_side_effect_without_inbound_context(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
gateway, client = _prepared_gateway(monkeypatch)
|
|
|
|
result = await gateway.handle_manual_fix_done(
|
|
user_id=123,
|
|
username="operator",
|
|
fix_steps="restart",
|
|
)
|
|
|
|
assert result == {
|
|
"success": False,
|
|
"reason": "missing_verified_interaction_context",
|
|
}
|
|
assert client.posts == []
|
|
|
|
|
|
class _FakeDb:
|
|
def __init__(self) -> None:
|
|
self.params: list[dict] = []
|
|
|
|
async def execute(self, statement: object, params: dict) -> None:
|
|
del statement
|
|
self.params.append(params)
|
|
|
|
|
|
class _FakeRedis:
|
|
async def setex(self, key: str, ttl: int, value: str) -> None:
|
|
del key, ttl, value
|
|
|
|
|
|
def _patch_approval_dependencies(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
gateway: TelegramGateway,
|
|
) -> _FakeDb:
|
|
fake_db = _FakeDb()
|
|
|
|
@asynccontextmanager
|
|
async def fake_db_context(*args, **kwargs):
|
|
del args, kwargs
|
|
yield fake_db
|
|
|
|
monkeypatch.setattr(db_base, "get_db_context", fake_db_context)
|
|
monkeypatch.setattr(
|
|
approval_db,
|
|
"get_approval_service",
|
|
lambda: SimpleNamespace(update_telegram_message=True),
|
|
)
|
|
monkeypatch.setattr(gateway_module, "get_redis", lambda: _FakeRedis())
|
|
monkeypatch.setattr(
|
|
gateway_module,
|
|
"_fetch_remediation_summary_for_card",
|
|
AsyncMock(return_value=None),
|
|
)
|
|
monkeypatch.setattr(gateway, "_build_inline_keyboard", AsyncMock(return_value={}))
|
|
return fake_db
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approval_missing_type_is_blocked_without_provider_call(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
gateway, client = _prepared_gateway(monkeypatch)
|
|
_patch_approval_dependencies(monkeypatch, gateway)
|
|
|
|
result = await gateway.send_approval_card(
|
|
approval_id="APR-TEST",
|
|
risk_level="critical",
|
|
resource_name="resource",
|
|
root_cause="cause",
|
|
suggested_action="review",
|
|
notification_type="",
|
|
)
|
|
|
|
assert result["_awooop_delivery_status"] == "blocked_no_egress"
|
|
assert client.posts == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allowed_type3_approval_persists_runtime_chat_without_public_id(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
gateway, client = _prepared_gateway(monkeypatch)
|
|
fake_db = _patch_approval_dependencies(monkeypatch, gateway)
|
|
monkeypatch.setattr(gateway, "canonical_destination_chat_id", lambda **kwargs: "7")
|
|
|
|
result = await gateway.send_approval_card(
|
|
approval_id="APR-TEST",
|
|
risk_level="critical",
|
|
resource_name="resource",
|
|
root_cause="cause",
|
|
suggested_action="review",
|
|
notification_type="TYPE-3",
|
|
)
|
|
|
|
assert result["ok"] is True
|
|
assert len(client.posts) == 1
|
|
assert any(params.get("cid") == 7 for params in fake_db.params)
|
|
receipt = result["_awoooi_canonical_route_receipt"]
|
|
assert receipt["destination_alias"] == "awoooi_sre_war_room"
|
|
assert "chat_id" not in receipt
|