from __future__ import annotations import json from types import SimpleNamespace from unittest.mock import AsyncMock from uuid import UUID import pytest import src.repositories.drift_repository as drift_repository import src.repositories.incident_repository as incident_repository import src.services.ai_advisory_helpers as ai_advisory_helpers import src.services.approval_db as approval_db import src.services.callback_dispatcher as callback_dispatcher import src.services.telegram_gateway as gateway_module from src.models.approval import ApprovalStatus from src.models.incident import IncidentStatus from src.services.callback_dispatcher import DispatchResult from src.services.telegram_gateway import TelegramGateway _CHAT_ID = "verified-chat" _MESSAGE_ID = 77 def _assert_verified_callback_payload(payload: dict) -> None: assert payload["chat_id"] == _CHAT_ID assert payload["reply_to_message_id"] == _MESSAGE_ID assert payload[gateway_module._NON_MONITORING_INTERACTION_KEY] == { "interaction_kind": "callback_reply", "inbound_chat_id": _CHAT_ID, "inbound_message_id": _MESSAGE_ID, } class _DriftRepo: def __init__(self, value=None, error: Exception | None = None) -> None: self.value = value self.error = error async def get_by_id(self, _report_id: str): if self.error: raise self.error return self.value @pytest.mark.asyncio @pytest.mark.parametrize( "repo", [ _DriftRepo(), _DriftRepo(SimpleNamespace(items=[], namespace="awoooi-prod")), _DriftRepo(error=RuntimeError("read failed")), ], ) async def test_drift_detail_page_and_error_keep_verified_callback_context( monkeypatch: pytest.MonkeyPatch, repo: _DriftRepo, ) -> None: gateway = TelegramGateway() send_request = AsyncMock(return_value={"ok": True}) monkeypatch.setattr(gateway, "_send_request", send_request) monkeypatch.setattr(drift_repository, "get_drift_repository", lambda: repo) await gateway._send_drift_diff_detail( "drift-1", inbound_chat_id=_CHAT_ID, inbound_message_id=_MESSAGE_ID, ) send_request.assert_awaited_once() method, payload = send_request.await_args.args assert method == "sendMessage" _assert_verified_callback_payload(payload) @pytest.mark.asyncio async def test_advisory_and_drift_outcome_keep_verified_callback_context( monkeypatch: pytest.MonkeyPatch, ) -> None: gateway = TelegramGateway() send_request = AsyncMock(return_value={"ok": True}) monkeypatch.setattr(gateway, "_send_request", send_request) monkeypatch.setattr(gateway, "_answer_callback", AsyncMock()) monkeypatch.setattr( ai_advisory_helpers, "handle_ai_advisory_callback", AsyncMock(return_value={"feedback_text": "done", "success": True}), ) await gateway._handle_ai_advisory_action( action="ai_advisory_view", advisory_payload="capacity:item-1", callback_query_id="callback-1", user_id=42, username="operator", user={"id": 42}, inbound_chat_id=_CHAT_ID, inbound_message_id=_MESSAGE_ID, ) _assert_verified_callback_payload(send_request.await_args.args[1]) send_request.reset_mock() class _Redis: async def get(self, _key: str) -> str: return str(_MESSAGE_ID) monkeypatch.setattr(gateway_module, "get_redis", lambda: _Redis()) await gateway._edit_drift_card_outcome( report_id="drift-1", verb="adopted", by="operator", ok=True, inbound_chat_id=_CHAT_ID, inbound_message_id=_MESSAGE_ID, ) send_payload = next( call.args[1] for call in send_request.await_args_list if call.args[0] == "sendMessage" ) _assert_verified_callback_payload(send_payload) @pytest.mark.asyncio async def test_drift_outcome_ignores_stale_redis_parent_receipt( monkeypatch: pytest.MonkeyPatch, ) -> None: gateway = TelegramGateway() send_request = AsyncMock(return_value={"ok": True}) redis_reads: list[str] = [] class _Redis: async def get(self, key: str) -> str: redis_reads.append(key) return "999" monkeypatch.setattr(gateway, "_send_request", send_request) monkeypatch.setattr(gateway_module, "get_redis", lambda: _Redis()) await gateway._edit_drift_card_outcome( report_id="drift-stale-parent", verb="adopted", by="operator", ok=True, inbound_chat_id=_CHAT_ID, inbound_message_id=_MESSAGE_ID, ) assert redis_reads == ["tg_drift:drift-stale-parent"] assert [call.args[0] for call in send_request.await_args_list] == [ "editMessageReplyMarkup", "sendMessage", ] edit_payload = send_request.await_args_list[0].args[1] assert edit_payload["chat_id"] == _CHAT_ID assert edit_payload["message_id"] == _MESSAGE_ID send_payload = send_request.await_args_list[1].args[1] _assert_verified_callback_payload(send_payload) for call in send_request.await_args_list: provider_payload = call.args[1] assert provider_payload.get("message_id") != 999 assert provider_payload.get("reply_to_message_id") != 999 @pytest.mark.asyncio async def test_llm_and_category_results_keep_verified_callback_context( monkeypatch: pytest.MonkeyPatch, ) -> None: gateway = TelegramGateway() send_request = AsyncMock(return_value={"ok": True}) monkeypatch.setattr(gateway, "_send_request", send_request) monkeypatch.setattr(gateway._security, "is_whitelisted", lambda _user_id: True) class _Redis: async def get(self, _key: str) -> str: return json.dumps({ "name": "inspect", "provider": "internal", "tool": "status", "risk": "low", "incident_id": "INC-1", }) monkeypatch.setattr(gateway_module, "get_redis", lambda: _Redis()) monkeypatch.setattr( callback_dispatcher, "dispatch_llm_action", lambda _action, _context: {"ok": True}, ) await gateway._handle_llm_action_callback( callback_query_id="callback-1", callback_data="la:short", user_id=42, username="operator", inbound_chat_id=_CHAT_ID, inbound_message_id=_MESSAGE_ID, ) llm_payload = next( call.args[1] for call in send_request.await_args_list if call.args[0] == "sendMessage" ) _assert_verified_callback_payload(llm_payload) send_request.reset_mock() monkeypatch.setattr(gateway, "_answer_callback", AsyncMock()) monkeypatch.setattr( callback_dispatcher, "get_action_spec", lambda _action: SimpleNamespace(emoji="🔎"), ) monkeypatch.setattr( callback_dispatcher, "dispatch_action", AsyncMock(return_value=DispatchResult( success=True, action="inspect", incident_id="INC-1", user_id=42, result_text="result", )), ) class _IncidentRepo: async def get_by_id(self, _incident_id: str): return SimpleNamespace(signals=[]) monkeypatch.setattr( incident_repository, "get_incident_repository", lambda: _IncidentRepo(), ) await gateway._dispatch_category_action( callback_query_id="callback-2", action="inspect", incident_id="INC-1", user_id=42, inbound_chat_id=_CHAT_ID, inbound_message_id=_MESSAGE_ID, ) _assert_verified_callback_payload(send_request.await_args.args[1]) @pytest.mark.asyncio async def test_write_category_callback_keeps_verified_context( monkeypatch: pytest.MonkeyPatch, ) -> None: gateway = TelegramGateway() send_request = AsyncMock(return_value={"ok": True}) monkeypatch.setattr(gateway, "_send_request", send_request) monkeypatch.setattr( gateway._security, "parse_callback_data", lambda _data: { "action": "restart_safe", "approval_id": "INC-1", "is_info_action": False, "nonce": "nonce", }, ) monkeypatch.setattr( gateway._security, "verify_callback", AsyncMock(return_value={"id": 42}), ) monkeypatch.setattr(gateway, "_check_incident_state_guard", AsyncMock(return_value=None)) monkeypatch.setattr(gateway, "_answer_callback", AsyncMock()) spec = SimpleNamespace( requires_multi_sig=False, risk="medium", mcp_provider="internal", mcp_tool="restart_safe", emoji="▶️", label="restart", ) monkeypatch.setattr(callback_dispatcher, "get_action_spec", lambda _action: spec) monkeypatch.setattr( callback_dispatcher, "dispatch_action", AsyncMock(return_value=DispatchResult( success=True, action="restart_safe", incident_id="INC-1", user_id=42, result_text="done", )), ) class _IncidentRepo: async def get_by_id(self, _incident_id: str): return SimpleNamespace(incident_id="INC-1", signals=[]) monkeypatch.setattr( incident_repository, "get_incident_repository", lambda: _IncidentRepo(), ) result = await gateway.handle_callback( callback_query_id="callback-1", callback_data="restart_safe:INC-1:nonce", user_id=42, message_id=_MESSAGE_ID, username="operator", chat_id=_CHAT_ID, ) assert result["success"] is True _assert_verified_callback_payload(send_request.await_args.args[1]) @pytest.mark.asyncio async def test_approval_replies_keep_verified_callback_context( monkeypatch: pytest.MonkeyPatch, ) -> None: gateway = TelegramGateway() send_request = AsyncMock(return_value={"ok": True}) monkeypatch.setattr(gateway, "_send_request", send_request) await gateway._notify_approval_result( message_id=_MESSAGE_ID, incident_id="INC-1", action="approve", username="operator", execution_triggered=False, inbound_chat_id=_CHAT_ID, inbound_message_id=_MESSAGE_ID, ) status_payload = next( call.args[1] for call in send_request.await_args_list if call.args[0] == "sendMessage" ) _assert_verified_callback_payload(status_payload) source_extra = status_payload[gateway_module._AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY] merged = gateway_module._merge_outbound_source_envelope_extra({}, source_extra) assert merged["callback_reply"]["parent_provider_message_id"] == str( _MESSAGE_ID ) send_request.reset_mock() approval_id = UUID("44444444-4444-4444-4444-444444444444") terminal = SimpleNamespace( id=approval_id, status=ApprovalStatus.EXECUTION_SUCCESS, action="restart_safe", metadata={}, ) class _ApprovalService: async def sign_approval(self, **_kwargs): return terminal, "already done", False monkeypatch.setattr(approval_db, "get_approval_service", lambda: _ApprovalService()) await gateway._execute_approval_action( action="approve", approval_id=str(approval_id), user_id=42, username="operator", message_id=_MESSAGE_ID, inbound_chat_id=_CHAT_ID, ) _assert_verified_callback_payload(send_request.await_args.args[1]) @pytest.mark.parametrize( ("result", "expected"), [ ({"ok": True}, True), ({"ok": True, "_awooop_delivery_status": "sent_reused"}, True), ({"ok": False}, False), ( {"ok": True, "_awooop_delivery_status": "blocked_no_egress"}, False, ), ({"ok": True, "_awooop_provider_send_performed": False}, False), ( { "ok": True, "_awoooi_canonical_route_receipt": { "provider_send_performed": False, }, }, False, ), ], ) def test_telegram_send_delivery_requires_affirmative_provider_receipt( result: dict, expected: bool, ) -> None: assert gateway_module._telegram_send_delivery_succeeded(result) is expected @pytest.mark.asyncio @pytest.mark.parametrize( "primary_failure", [ {"ok": False}, {"ok": True, "_awooop_delivery_status": "blocked_no_egress"}, {"ok": True, "_awooop_provider_send_performed": False}, { "ok": True, "_awoooi_canonical_route_receipt": { "provider_send_performed": False, }, }, ], ) async def test_approval_structured_no_send_retries_plain_on_exact_parent( monkeypatch: pytest.MonkeyPatch, primary_failure: dict, ) -> None: gateway = TelegramGateway() attempts: list[tuple[str, dict]] = [] send_results = iter([primary_failure, {"ok": True}]) async def structured_primary_failure(method: str, payload: dict) -> dict: attempts.append((method, payload)) if method == "sendMessage": return next(send_results) return {"ok": True} record_failure = AsyncMock(return_value=None) shared_fallback = AsyncMock(return_value={"ok": True}) monkeypatch.setattr(gateway, "_send_request", structured_primary_failure) monkeypatch.setattr(gateway, "_record_callback_reply_failure", record_failure) monkeypatch.setattr(gateway, "send_alert_notification", shared_fallback) await gateway._notify_approval_result( message_id=_MESSAGE_ID, incident_id="INC-1", action="approve", username="operator", execution_triggered=False, inbound_chat_id=_CHAT_ID, inbound_message_id=_MESSAGE_ID, ) send_attempts = [payload for method, payload in attempts if method == "sendMessage"] assert len(send_attempts) == 2 for payload in send_attempts: _assert_verified_callback_payload(payload) assert send_attempts[0]["parse_mode"] == "HTML" assert "parse_mode" not in send_attempts[1] record_failure.assert_not_awaited() shared_fallback.assert_not_awaited() @pytest.mark.asyncio async def test_approval_structured_double_no_send_records_durable_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: gateway = TelegramGateway() attempts: list[tuple[str, dict]] = [] send_results = iter([ {"ok": True, "_awooop_delivery_status": "blocked_no_egress"}, {"ok": True, "_awooop_provider_send_performed": False}, ]) async def structured_no_send(method: str, payload: dict) -> dict: attempts.append((method, payload)) if method == "sendMessage": return next(send_results) return {"ok": True} record_failure = AsyncMock(return_value=None) shared_fallback = AsyncMock(return_value={"ok": True}) monkeypatch.setattr(gateway, "_send_request", structured_no_send) monkeypatch.setattr(gateway, "_record_callback_reply_failure", record_failure) monkeypatch.setattr(gateway, "send_alert_notification", shared_fallback) await gateway._notify_approval_result( message_id=_MESSAGE_ID, incident_id="INC-1", action="reject", username="operator", execution_triggered=False, inbound_chat_id=_CHAT_ID, inbound_message_id=_MESSAGE_ID, ) send_attempts = [payload for method, payload in attempts if method == "sendMessage"] assert len(send_attempts) == 2 for payload in send_attempts: _assert_verified_callback_payload(payload) shared_fallback.assert_not_awaited() record_failure.assert_awaited_once() assert record_failure.await_args.kwargs["parent_message_id"] == _MESSAGE_ID assert record_failure.await_args.kwargs["incident_id"] == "INC-1" @pytest.mark.asyncio @pytest.mark.parametrize( "action", ["tune", "snooze", "silence", "approve", "reject"], ) async def test_callback_card_edits_use_only_verified_inbound_identity( monkeypatch: pytest.MonkeyPatch, action: str, ) -> None: gateway = TelegramGateway() send_request = AsyncMock(return_value={"ok": True}) monkeypatch.setattr(gateway, "_send_request", send_request) await gateway._update_message_after_action( action=action, username="operator", original_text="original", inbound_chat_id=_CHAT_ID, inbound_message_id=_MESSAGE_ID, ) assert [call.args[0] for call in send_request.await_args_list] == [ "editMessageReplyMarkup", "editMessageText", ] for call in send_request.await_args_list: payload = call.args[1] assert payload["chat_id"] == _CHAT_ID assert payload["message_id"] == _MESSAGE_ID @pytest.mark.asyncio async def test_resolved_state_guard_edits_only_verified_inbound_message( monkeypatch: pytest.MonkeyPatch, ) -> None: gateway = TelegramGateway() send_request = AsyncMock(return_value={"ok": True}) monkeypatch.setattr(gateway, "_send_request", send_request) monkeypatch.setattr(gateway, "_answer_callback", AsyncMock()) approval_id = UUID("55555555-5555-5555-5555-555555555555") class _ApprovalService: async def get_approval_by_id(self, _approval_id: UUID): return SimpleNamespace(incident_id="INC-1") class _IncidentRepo: async def get_by_id(self, _incident_id: str): return SimpleNamespace( status=IncidentStatus.RESOLVED, resolved_at=None, ) monkeypatch.setattr(approval_db, "get_approval_service", lambda: _ApprovalService()) monkeypatch.setattr( incident_repository, "get_incident_repository", lambda: _IncidentRepo(), ) result = await gateway._check_incident_state_guard( approval_id=str(approval_id), callback_query_id="callback-1", inbound_chat_id=_CHAT_ID, inbound_message_id=_MESSAGE_ID, original_text="original", ) assert result == { "blocked": True, "reason": "already_resolved", "approval_id": str(approval_id), } method, payload = send_request.await_args.args assert method == "editMessageText" assert payload["chat_id"] == _CHAT_ID assert payload["message_id"] == _MESSAGE_ID @pytest.mark.asyncio async def test_approval_reply_failure_retries_exact_parent_then_records_no_send( monkeypatch: pytest.MonkeyPatch, ) -> None: gateway = TelegramGateway() attempts: list[tuple[str, dict]] = [] async def fail_replies(method: str, payload: dict) -> dict: attempts.append((method, payload)) if method == "sendMessage": raise gateway_module.TelegramGatewayError("provider unavailable") return {"ok": True} record_failure = AsyncMock(return_value=None) shared_fallback = AsyncMock(return_value={"ok": True}) monkeypatch.setattr(gateway, "_send_request", fail_replies) monkeypatch.setattr(gateway, "_record_callback_reply_failure", record_failure) monkeypatch.setattr(gateway, "send_alert_notification", shared_fallback) await gateway._notify_approval_result( message_id=_MESSAGE_ID, incident_id="INC-1", action="reject", username="operator", execution_triggered=False, inbound_chat_id=_CHAT_ID, inbound_message_id=_MESSAGE_ID, ) send_attempts = [payload for method, payload in attempts if method == "sendMessage"] assert len(send_attempts) == 2 for payload in send_attempts: _assert_verified_callback_payload(payload) extra = payload[gateway_module._AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY] merged = gateway_module._merge_outbound_source_envelope_extra({}, extra) assert merged["callback_reply"]["parent_provider_message_id"] == str( _MESSAGE_ID ) assert "parse_mode" in send_attempts[0] assert "parse_mode" not in send_attempts[1] shared_fallback.assert_not_awaited() record_failure.assert_awaited_once() assert record_failure.await_args.kwargs["parent_message_id"] == _MESSAGE_ID assert record_failure.await_args.kwargs["incident_id"] == "INC-1" @pytest.mark.asyncio async def test_approval_reply_parent_mismatch_is_durable_no_send_terminal( monkeypatch: pytest.MonkeyPatch, ) -> None: gateway = TelegramGateway() send_request = AsyncMock(return_value={"ok": True}) record_failure = AsyncMock(return_value=None) shared_fallback = AsyncMock(return_value={"ok": True}) monkeypatch.setattr(gateway, "_send_request", send_request) monkeypatch.setattr(gateway, "_record_callback_reply_failure", record_failure) monkeypatch.setattr(gateway, "send_alert_notification", shared_fallback) await gateway._notify_approval_result( message_id=_MESSAGE_ID + 1, incident_id="INC-1", action="approve", username="operator", execution_triggered=False, inbound_chat_id=_CHAT_ID, inbound_message_id=_MESSAGE_ID, ) send_request.assert_not_awaited() shared_fallback.assert_not_awaited() record_failure.assert_awaited_once() assert record_failure.await_args.kwargs["parent_message_id"] == _MESSAGE_ID class _LifecycleRedis: async def get(self, key: str) -> str: if key.startswith("tg_approval:") or key.startswith("tg_msg:"): return str(_MESSAGE_ID) raise AssertionError(key) async def set(self, *_args, **_kwargs) -> bool: return True @pytest.mark.asyncio async def test_autonomous_lifecycle_threads_use_canonical_route_and_parent_receipt( monkeypatch: pytest.MonkeyPatch, ) -> None: gateway = TelegramGateway() send_request = AsyncMock(return_value={"ok": True}) monkeypatch.setattr(gateway, "_send_request", send_request) monkeypatch.setattr(gateway_module, "get_redis", lambda: _LifecycleRedis()) assert await gateway.mark_auto_repaired("INC-1", "playbook", 10) is True assert await gateway.append_incident_update("INC-2", "healthy") is True assert await gateway.append_grouped_alert_digest( incident_id="INC-3", group_key="group-1", digest_text="digest", ) is True send_payloads = [ call.args[1] for call in send_request.await_args_list if call.args[0] == "sendMessage" ] assert len(send_payloads) == 3 parent_lookup_keys = [] for payload in send_payloads: assert gateway_module._NON_MONITORING_INTERACTION_KEY not in payload assert payload[gateway_module._CANONICAL_ROUTE_CONTEXT_KEY] == { "product_id": "awoooi", "signal_family": "incident_lifecycle", "severity": "P1", } extra = payload[gateway_module._AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY] merged = gateway_module._merge_outbound_source_envelope_extra({}, extra) receipt = merged["incident_lifecycle_parent_receipt"] assert receipt["parent_provider_message_id"] == str(_MESSAGE_ID) assert receipt["durable_mirror_required"] is True parent_lookup_keys.append(receipt["parent_lookup_key"]) assert merged["source_refs"]["parent_provider_message_ids"] == [ str(_MESSAGE_ID) ] assert parent_lookup_keys == [ "tg_approval:INC-1", "tg_msg:INC-2", "tg_msg:INC-3", ] @pytest.mark.asyncio async def test_callback_fails_closed_before_dispatch_without_inbound_identity( monkeypatch: pytest.MonkeyPatch, ) -> None: gateway = TelegramGateway() llm_handler = AsyncMock() monkeypatch.setattr(gateway, "_handle_llm_action_callback", llm_handler) result = await gateway.handle_callback( callback_query_id="callback-1", callback_data="la:short", user_id=42, message_id=_MESSAGE_ID, chat_id=None, ) assert result["success"] is False assert result["reason"] == "missing_verified_interaction_context" llm_handler.assert_not_awaited()