diff --git a/apps/api/src/services/agent99_controlled_dispatch_ledger.py b/apps/api/src/services/agent99_controlled_dispatch_ledger.py index dae084efb..27ce37e96 100644 --- a/apps/api/src/services/agent99_controlled_dispatch_ledger.py +++ b/apps/api/src/services/agent99_controlled_dispatch_ledger.py @@ -495,10 +495,10 @@ def build_agent99_dispatch_receipt_envelope( "step_seq": 3, **stage_identity, "required_receipts": [ - "incident_closure_receipt", - "telegram_lifecycle_receipt", - "km_writeback_ack", - "playbook_trust_writeback_ack", + "incident_closure_receipt_id", + "telegram_lifecycle_receipt_id", + "km_writeback_ack_id", + "playbook_trust_writeback_ack_id", ], }, } @@ -1350,10 +1350,10 @@ class PostgresAgent99DispatchLedger: else "blocked_verifier_failed" ), "required_receipts": [ - "incident_closure_receipt", - "telegram_lifecycle_receipt", - "km_writeback_ack", - "playbook_trust_writeback_ack", + "incident_closure_receipt_id", + "telegram_lifecycle_receipt_id", + "km_writeback_ack_id", + "playbook_trust_writeback_ack_id", ], }, }) diff --git a/apps/api/src/services/agent99_telegram_lifecycle.py b/apps/api/src/services/agent99_telegram_lifecycle.py index c227b9b37..1d7722538 100644 --- a/apps/api/src/services/agent99_telegram_lifecycle.py +++ b/apps/api/src/services/agent99_telegram_lifecycle.py @@ -50,6 +50,12 @@ _LIFECYCLE_STAGES = ( ) _DURABLE_RECONCILE_DELAYS_SECONDS = (0.25, 0.5, 1.0) +_PROVIDER_DESTINATION_VERIFICATION_METHODS = frozenset( + { + "requested_chat_id_matches_provider_chat_id", + "requested_username_matches_provider_username", + } +) def _escape_bounded(value: object, limit: int) -> str: @@ -86,10 +92,20 @@ def _bounded_reconcile_receipt( *, provider_message_id: str, destination_binding: str, + provider_destination_binding: str, + provider_destination_verification_method: str, ) -> dict[str, str] | None: """Return the only client input allowed to resume a pending DB ack.""" - if not provider_message_id or not destination_binding: + if ( + not provider_message_id + or not destination_binding + or not provider_destination_binding + or len(destination_binding) != 64 + or len(provider_destination_binding) != 64 + or provider_destination_verification_method + not in _PROVIDER_DESTINATION_VERIFICATION_METHODS + ): return None return { "schema_version": "agent99_telegram_lifecycle_reconcile_v1", @@ -250,6 +266,15 @@ async def deliver_agent99_telegram_lifecycle( initial_destination_binding = str( initial_delivery_context.get("destination_binding") or "" ) + initial_provider_destination_binding = str( + initial_delivery_context.get("provider_destination_binding") or "" + ) + initial_provider_destination_verification_method = str( + initial_delivery_context.get( + "provider_destination_verification_method" + ) + or "" + ) reconciliation_attempts = 0 # A provider send can finish before the durable outbox finalizer becomes @@ -279,6 +304,12 @@ async def deliver_agent99_telegram_lifecycle( expected_destination_binding=( initial_destination_binding or None ), + expected_provider_destination_binding=( + initial_provider_destination_binding or None + ), + expected_provider_destination_verification_method=( + initial_provider_destination_verification_method or None + ), ) result = response.get("result") if isinstance(response, dict) else None @@ -303,14 +334,31 @@ async def deliver_agent99_telegram_lifecycle( or initial_destination_binding or "" ) + provider_destination_binding = str( + delivery_context.get("provider_destination_binding") + or initial_provider_destination_binding + or "" + ) + provider_destination_verification_method = str( + delivery_context.get("provider_destination_verification_method") + or initial_provider_destination_verification_method + or "" + ) durable_ack = bool( isinstance(response, dict) and response.get("_awooop_outbound_mirror_acknowledged") is True ) + provider_destination_proof_verified = bool( + provider_destination_binding + and provider_destination_verification_method + in _PROVIDER_DESTINATION_VERIFICATION_METHODS + ) destination_verified = bool( - delivery_status == "sent_reused" - or provider_destination_verified - or delivery_context.get("destination_binding_verified") is True + provider_destination_proof_verified + and ( + provider_destination_verified + or delivery_context.get("destination_binding_verified") is True + ) ) ok = bool( durable_ack @@ -334,6 +382,12 @@ async def deliver_agent99_telegram_lifecycle( "lifecycle": payload.lifecycle, "provider_message_id": message_id or None, "destination_binding": destination_binding or None, + "provider_destination_binding": ( + provider_destination_binding or None + ), + "provider_destination_verification_method": ( + provider_destination_verification_method or None + ), "delivery_status": delivery_status or "unknown", "durable_outbound_acknowledged": durable_ack, "destination_binding_verified": destination_verified, @@ -343,6 +397,10 @@ async def deliver_agent99_telegram_lifecycle( payload, provider_message_id=message_id, destination_binding=destination_binding, + provider_destination_binding=provider_destination_binding, + provider_destination_verification_method=( + provider_destination_verification_method + ), ) if ( delivery_status == "pending_unknown" @@ -403,6 +461,12 @@ async def reconcile_agent99_telegram_lifecycle( destination_binding = str( delivery_context.get("destination_binding") or "" ) + provider_destination_binding = str( + delivery_context.get("provider_destination_binding") or "" + ) + provider_destination_verification_method = str( + delivery_context.get("provider_destination_verification_method") or "" + ) durable_ack = bool( isinstance(response, dict) and response.get("_awooop_outbound_mirror_acknowledged") is True @@ -410,6 +474,9 @@ async def reconcile_agent99_telegram_lifecycle( destination_verified = bool( delivery_context.get("destination_binding_verified") is True and destination_binding == receipt.destination_binding + and provider_destination_binding + and provider_destination_verification_method + in _PROVIDER_DESTINATION_VERIFICATION_METHODS ) ok = bool( durable_ack @@ -433,6 +500,12 @@ async def reconcile_agent99_telegram_lifecycle( "lifecycle": payload.lifecycle, "provider_message_id": provider_message_id or None, "destination_binding": destination_binding or None, + "provider_destination_binding": ( + provider_destination_binding or None + ), + "provider_destination_verification_method": ( + provider_destination_verification_method or None + ), "delivery_status": delivery_status or "unknown", "durable_outbound_acknowledged": durable_ack, "destination_binding_verified": destination_verified, diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index b3cf76e9f..7c7885079 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -259,6 +259,63 @@ def _telegram_provider_delivery_evidence( return message_ids, destination_bindings.pop() +_TELEGRAM_PROVIDER_DESTINATION_VERIFICATION_METHODS = frozenset( + { + "requested_chat_id_matches_provider_chat_id", + "requested_username_matches_provider_username", + } +) + + +def _telegram_provider_destination_verification_method( + provider_result: object, + requested_chat_id: object, +) -> str | None: + """Verify Telegram's resolved chat against the requested destination. + + Telegram returns a numeric chat id even when the request used an + ``@username``. Hashing those two representations and comparing the + hashes produces a false mismatch. Verify numeric destinations by id and + username destinations by the provider-returned username instead. Titles + are intentionally not accepted as identity evidence. + """ + + requested = str(requested_chat_id or "").strip() + if not requested: + return None + requested_username = ( + requested[1:].casefold() + if requested.startswith("@") and len(requested) > 1 + else "" + ) + if isinstance(provider_result, Mapping): + messages = [provider_result] + elif isinstance(provider_result, list) and provider_result: + messages = provider_result + else: + return None + + for message in messages: + if not isinstance(message, Mapping): + return None + provider_chat = message.get("chat") + if not isinstance(provider_chat, Mapping): + return None + if requested_username: + provider_username = str( + provider_chat.get("username") or "" + ).strip().lstrip("@").casefold() + if provider_username != requested_username: + return None + elif str(provider_chat.get("id") or "").strip() != requested: + return None + return ( + "requested_username_matches_provider_username" + if requested_username + else "requested_chat_id_matches_provider_chat_id" + ) + + _CONTROLLED_APPLY_OUTBOUND_FINALIZE_ATTEMPTS = 3 _NO_WRITE_REPLAY_DIGEST_WINDOW_MINUTES = 30 _HOST_RESOURCE_ALERT_HEADER_RE = re.compile( @@ -1169,6 +1226,9 @@ def _telegram_send_delivery_succeeded(result: object) -> bool: provider_destination_binding = str( delivery_context.get("provider_destination_binding") or "" ).strip() + provider_destination_verification_method = str( + delivery_context.get("provider_destination_verification_method") or "" + ).strip() if ( not route_destination_binding or len( @@ -1176,10 +1236,15 @@ def _telegram_send_delivery_succeeded(result: object) -> bool: route_destination_binding, final_exit_destination_binding, payload_destination_binding, - provider_destination_binding, } ) != 1 + or not provider_destination_binding + or ( + provider_destination_verification_method + not in _TELEGRAM_PROVIDER_DESTINATION_VERIFICATION_METHODS + and provider_destination_binding != route_destination_binding + ) ): return False @@ -6263,6 +6328,16 @@ class TelegramGateway: source_envelope #>> '{canonical_route_receipt,destination_binding}' AS destination_binding, + source_envelope #>> + '{canonical_route_receipt,provider_destination_binding}' + AS provider_destination_binding, + source_envelope #>> + '{canonical_route_receipt,provider_destination_verification_method}' + AS provider_destination_verification_method, + ( + source_envelope #>> + '{canonical_route_receipt,provider_destination_identity_verified}' + ) = 'true' AS provider_destination_identity_verified, ( source_envelope #>> '{visual_delivery,requested}' @@ -6468,6 +6543,33 @@ class TelegramGateway: ) if destination_binding: sent_receipt["destination_binding"] = destination_binding + provider_destination_binding = str( + existing_row.get("provider_destination_binding") or "" + ) + provider_destination_verification_method = str( + existing_row.get( + "provider_destination_verification_method" + ) + or "" + ) + if ( + provider_destination_binding + and provider_destination_verification_method + in _TELEGRAM_PROVIDER_DESTINATION_VERIFICATION_METHODS + and existing_row.get( + "provider_destination_identity_verified" + ) + is True + ): + sent_receipt["provider_destination_binding"] = ( + provider_destination_binding + ) + sent_receipt[ + "provider_destination_verification_method" + ] = provider_destination_verification_method + sent_receipt[ + "provider_destination_identity_verified" + ] = True return sent_receipt if existing_row.get("send_status") == "pending": pending_receipt: dict[str, object] = { @@ -6516,6 +6618,8 @@ class TelegramGateway: reservation: dict[str, object], provider_message_id: str, expected_destination_binding: str | None = None, + provider_destination_binding: str | None = None, + provider_destination_verification_method: str | None = None, ) -> bool: """Finalize only the reserved row, retrying DB acknowledgement only.""" from sqlalchemy import text @@ -6526,6 +6630,18 @@ class TelegramGateway: run_id = str(reservation.get("run_id") or "") if not message_id or not run_id or not provider_message_id: return False + expected_binding = str(expected_destination_binding or "").strip() + provider_binding = str(provider_destination_binding or "").strip() + verification_method = str( + provider_destination_verification_method or "" + ).strip() + if expected_binding and ( + re.fullmatch(r"[0-9a-f]{64}", expected_binding) is None + or re.fullmatch(r"[0-9a-f]{64}", provider_binding) is None + or verification_method + not in _TELEGRAM_PROVIDER_DESTINATION_VERIFICATION_METHODS + ): + return False delivery_kind = identity.get( "delivery_kind", @@ -6544,6 +6660,20 @@ class TelegramGateway: """), {"lock_key": lock_key}, ) + if expected_binding: + await db.execute( + text(""" + SELECT pg_advisory_xact_lock( + hashtextextended(:destination_lock_key, 0) + ) + """), + { + "destination_lock_key": ( + "telegram-destination-pin:" + f"{expected_binding}" + ) + }, + ) result = await db.execute( text(""" UPDATE awooop_outbound_message @@ -6581,27 +6711,52 @@ class TelegramGateway: jsonb_set( jsonb_set( jsonb_set( - source_envelope, - '{callback_reply,status}', - to_jsonb('callback_reply_sent'::text), + jsonb_set( + jsonb_set( + jsonb_set( + source_envelope, + '{callback_reply,status}', + to_jsonb('callback_reply_sent'::text), + true + ), + '{notification_policy,disposition}', + to_jsonb('sent'::text), + true + ), + '{canonical_route_receipt,durable_receipt_persisted}', + 'true'::jsonb, + true + ), + '{canonical_route_receipt,durable_receipt_status}', + to_jsonb('persisted'::text), true ), - '{notification_policy,disposition}', - to_jsonb('sent'::text), + '{canonical_route_receipt,destination_binding}', + to_jsonb( + CAST( + :expected_destination_binding + AS text + ) + ), true ), - '{canonical_route_receipt,durable_receipt_persisted}', - 'true'::jsonb, + '{canonical_route_receipt,provider_destination_binding}', + to_jsonb( + CAST( + :provider_destination_binding + AS text + ) + ), true ), - '{canonical_route_receipt,durable_receipt_status}', - to_jsonb('persisted'::text), - true + '{canonical_route_receipt,provider_destination_identity_verified}', + 'true'::jsonb, + true ), - '{canonical_route_receipt,destination_binding}', + '{canonical_route_receipt,provider_destination_verification_method}', to_jsonb( CAST( - :expected_destination_binding + :provider_destination_verification_method AS text ) ), @@ -6625,6 +6780,27 @@ class TelegramGateway: '' ) IN ('', :expected_destination_binding) ) + AND ( + :provider_destination_binding = '' + OR NOT EXISTS ( + SELECT 1 + FROM awooop_outbound_message AS pinned + WHERE pinned.project_id = :project_id + AND pinned.channel_type = 'telegram' + AND pinned.send_status = 'sent' + AND pinned.source_envelope #>> + '{canonical_route_receipt,destination_binding}' = + :expected_destination_binding + AND COALESCE( + pinned.source_envelope #>> + '{canonical_route_receipt,provider_destination_binding}', + '' + ) NOT IN ( + '', + :provider_destination_binding + ) + ) + ) RETURNING send_status, provider_message_id """), { @@ -6633,7 +6809,11 @@ class TelegramGateway: "run_id": run_id, "provider_message_id": provider_message_id, "expected_destination_binding": str( - expected_destination_binding or "" + expected_binding + ), + "provider_destination_binding": provider_binding, + "provider_destination_verification_method": ( + verification_method ), "triggered_by_state": delivery_kind, }, @@ -6683,6 +6863,16 @@ class TelegramGateway: source_envelope #>> '{canonical_route_receipt,destination_binding}' AS destination_binding, + source_envelope #>> + '{canonical_route_receipt,provider_destination_binding}' + AS provider_destination_binding, + source_envelope #>> + '{canonical_route_receipt,provider_destination_verification_method}' + AS provider_destination_verification_method, + ( + source_envelope #>> + '{canonical_route_receipt,provider_destination_identity_verified}' + ) = 'true' AS provider_destination_identity_verified, ( source_envelope #>> '{visual_delivery,requested}' @@ -6753,6 +6943,25 @@ class TelegramGateway: ) if destination_binding: sent_receipt["destination_binding"] = destination_binding + provider_destination_binding = str( + readback.get("provider_destination_binding") or "" + ) + provider_destination_verification_method = str( + readback.get("provider_destination_verification_method") or "" + ) + if ( + provider_destination_binding + and provider_destination_verification_method + in _TELEGRAM_PROVIDER_DESTINATION_VERIFICATION_METHODS + and readback.get("provider_destination_identity_verified") is True + ): + sent_receipt["provider_destination_binding"] = ( + provider_destination_binding + ) + sent_receipt["provider_destination_verification_method"] = ( + provider_destination_verification_method + ) + sent_receipt["provider_destination_identity_verified"] = True return sent_receipt if send_status == "shadow": return { @@ -6784,6 +6993,8 @@ class TelegramGateway: identity: dict[str, str], expected_provider_message_id: str | None = None, expected_destination_binding: str | None = None, + expected_provider_destination_binding: str | None = None, + expected_provider_destination_verification_method: str | None = None, ) -> dict[str, object]: """Finalize/read one existing row; never reserve or contact Telegram.""" readback = await self._read_controlled_apply_result_outbound( @@ -6793,6 +7004,12 @@ class TelegramGateway: destination_binding = str( expected_destination_binding or "" ).strip() + provider_destination_binding = str( + expected_provider_destination_binding or "" + ).strip() + provider_destination_verification_method = str( + expected_provider_destination_verification_method or "" + ).strip() durable_destination_binding = str( readback.get("destination_binding") or "" ).strip() @@ -6816,6 +7033,33 @@ class TelegramGateway: **readback, "status": "provider_message_id_mismatch", } + durable_provider_destination_binding = str( + readback.get("provider_destination_binding") or "" + ) + durable_verification_method = str( + readback.get("provider_destination_verification_method") or "" + ) + if ( + not durable_provider_destination_binding + or durable_verification_method + not in _TELEGRAM_PROVIDER_DESTINATION_VERIFICATION_METHODS + or readback.get("provider_destination_identity_verified") + is not True + or ( + provider_destination_binding + and durable_provider_destination_binding + != provider_destination_binding + ) + or ( + provider_destination_verification_method + and durable_verification_method + != provider_destination_verification_method + ) + ): + return { + **readback, + "status": "provider_destination_proof_mismatch", + } return readback if readback.get("status") != "pending_unknown" or not provider_message_id: return readback @@ -6825,6 +7069,12 @@ class TelegramGateway: reservation=readback, provider_message_id=provider_message_id, expected_destination_binding=destination_binding or None, + provider_destination_binding=( + provider_destination_binding or None + ), + provider_destination_verification_method=( + provider_destination_verification_method or None + ), ) terminal_readback = await self._read_controlled_apply_result_outbound( identity=identity @@ -6836,6 +7086,25 @@ class TelegramGateway: **terminal_readback, "status": "provider_message_id_mismatch", } + if terminal_readback.get("status") == "sent" and ( + terminal_readback.get("provider_destination_identity_verified") + is not True + or str( + terminal_readback.get("provider_destination_binding") or "" + ) + != provider_destination_binding + or str( + terminal_readback.get( + "provider_destination_verification_method" + ) + or "" + ) + != provider_destination_verification_method + ): + return { + **terminal_readback, + "status": "provider_destination_proof_mismatch", + } return terminal_readback async def _send_request( @@ -7036,19 +7305,81 @@ class TelegramGateway: controlled_reservation.get("status") or "pending_unknown" ) if reservation_status == "sent": + reservation_destination_binding = str( + controlled_reservation.get("destination_binding") or "" + ) + reservation_provider_destination_binding = str( + controlled_reservation.get( + "provider_destination_binding" + ) + or "" + ) + reservation_provider_verification_method = str( + controlled_reservation.get( + "provider_destination_verification_method" + ) + or "" + ) + expected_route_binding = str( + (canonical_route_receipt or {}).get( + "destination_binding" + ) + or "" + ) + destination_binding_verified = bool( + expected_route_binding + and reservation_destination_binding + == expected_route_binding + and reservation_provider_destination_binding + and reservation_provider_verification_method + in _TELEGRAM_PROVIDER_DESTINATION_VERIFICATION_METHODS + and controlled_reservation.get( + "provider_destination_identity_verified" + ) + is True + ) return { - "ok": True, + "ok": destination_binding_verified, "result": { "message_id": controlled_reservation.get( "provider_message_id" ) }, - "_awooop_outbound_mirror_acknowledged": True, - "_awooop_delivery_status": "sent_reused", + "_awooop_outbound_mirror_acknowledged": ( + destination_binding_verified + ), + "_awooop_delivery_status": ( + "sent_reused" + if destination_binding_verified + else "provider_destination_proof_mismatch" + ), "_awooop_provider_send_performed": False, "_awooop_visual_delivery_verified": bool( - controlled_reservation.get("visual_requested") is True + destination_binding_verified + and controlled_reservation.get("visual_requested") + is True ), + "_awooop_delivery_context": { + "destination_binding": ( + reservation_destination_binding + ), + "provider_destination_binding": ( + reservation_provider_destination_binding + ), + "provider_destination_verification_method": ( + reservation_provider_verification_method + ), + "provider_destination_identity_verified": bool( + controlled_reservation.get( + "provider_destination_identity_verified" + ) + is True + ), + "destination_binding_verified": ( + destination_binding_verified + ), + "durable_exact_row_readback": True, + }, } if reservation_status == "suppressed": return { @@ -7121,6 +7452,15 @@ class TelegramGateway: provider_message_ids, provider_destination_binding = ( _telegram_provider_delivery_evidence(result_val) ) + provider_destination_verification_method = ( + _telegram_provider_destination_verification_method( + result_val, + payload.get("chat_id"), + ) + ) + provider_destination_identity_verified = bool( + provider_destination_verification_method + ) has_provider_message_id = bool(provider_message_ids) provider_message_id_value = ( provider_message_ids[0] @@ -7141,7 +7481,8 @@ class TelegramGateway: and route_destination_binding and route_destination_binding == payload_destination_binding - == provider_destination_binding + and provider_destination_binding + and provider_destination_identity_verified ) delivery_context = { "schema_version": "telegram_delivery_context_v1", @@ -7160,6 +7501,12 @@ class TelegramGateway: "provider_destination_binding": ( provider_destination_binding ), + "provider_destination_identity_verified": ( + provider_destination_identity_verified + ), + "provider_destination_verification_method": ( + provider_destination_verification_method + ), "destination_binding_verified": ( destination_binding_verified ), @@ -7209,6 +7556,12 @@ class TelegramGateway: expected_destination_binding=( route_destination_binding ), + provider_destination_binding=( + provider_destination_binding + ), + provider_destination_verification_method=( + provider_destination_verification_method + ), ) ) result["_awooop_outbound_mirror_acknowledged"] = finalized @@ -12582,6 +12935,8 @@ class TelegramGateway: project_id: str = "awoooi", expected_provider_message_id: str | None = None, expected_destination_binding: str | None = None, + expected_provider_destination_binding: str | None = None, + expected_provider_destination_verification_method: str | None = None, ) -> dict[str, object]: """Read/finalize one lifecycle outbox row without provider egress.""" identity = { @@ -12595,6 +12950,12 @@ class TelegramGateway: identity=identity, expected_provider_message_id=expected_provider_message_id, expected_destination_binding=expected_destination_binding, + expected_provider_destination_binding=( + expected_provider_destination_binding + ), + expected_provider_destination_verification_method=( + expected_provider_destination_verification_method + ), ) delivery_status = str(readback.get("status") or "unknown") provider_message_id = str( @@ -12603,7 +12964,20 @@ class TelegramGateway: destination_binding = str( readback.get("destination_binding") or "" ) - sent = delivery_status == "sent" and bool(provider_message_id) + provider_destination_binding = str( + readback.get("provider_destination_binding") or "" + ) + provider_destination_verification_method = str( + readback.get("provider_destination_verification_method") or "" + ) + sent = bool( + delivery_status == "sent" + and provider_message_id + and provider_destination_binding + and provider_destination_verification_method + in _TELEGRAM_PROVIDER_DESTINATION_VERIFICATION_METHODS + and readback.get("provider_destination_identity_verified") is True + ) destination_binding_verified = bool( sent and destination_binding @@ -12611,6 +12985,16 @@ class TelegramGateway: not expected_destination_binding or destination_binding == expected_destination_binding ) + and ( + not expected_provider_destination_binding + or provider_destination_binding + == expected_provider_destination_binding + ) + and ( + not expected_provider_destination_verification_method + or provider_destination_verification_method + == expected_provider_destination_verification_method + ) ) return { "ok": sent, @@ -12627,6 +13011,13 @@ class TelegramGateway: ), "_awooop_delivery_context": { "destination_binding": destination_binding, + "provider_destination_binding": provider_destination_binding, + "provider_destination_verification_method": ( + provider_destination_verification_method + ), + "provider_destination_identity_verified": bool( + readback.get("provider_destination_identity_verified") is True + ), "destination_binding_verified": destination_binding_verified, "durable_exact_row_readback": True, }, diff --git a/apps/api/tests/test_agent99_controlled_dispatch_ledger_contract.py b/apps/api/tests/test_agent99_controlled_dispatch_ledger_contract.py index 7ffadb731..c08a40afc 100644 --- a/apps/api/tests/test_agent99_controlled_dispatch_ledger_contract.py +++ b/apps/api/tests/test_agent99_controlled_dispatch_ledger_contract.py @@ -64,6 +64,12 @@ def test_accepted_without_inbox_trigger_is_delivery_unknown_not_authorized() -> "verifier": "", "route_id": "agent99:host_recovery:Recover", } + assert envelope["learning_writeback"]["required_receipts"] == [ + "incident_closure_receipt_id", + "telegram_lifecycle_receipt_id", + "km_writeback_ack_id", + "playbook_trust_writeback_ack_id", + ] @pytest.mark.asyncio diff --git a/apps/api/tests/test_agent99_telegram_lifecycle_api.py b/apps/api/tests/test_agent99_telegram_lifecycle_api.py index 172dba1a0..272740930 100644 --- a/apps/api/tests/test_agent99_telegram_lifecycle_api.py +++ b/apps/api/tests/test_agent99_telegram_lifecycle_api.py @@ -21,6 +21,28 @@ from src.models.agent99_completion import Agent99TelegramLifecycleRequest from src.services import agent99_telegram_lifecycle as lifecycle_service from src.services import telegram_gateway as gateway_service +DESTINATION_BINDING = "a" * 64 +PROVIDER_DESTINATION_BINDING = "b" * 64 +PROVIDER_DESTINATION_VERIFICATION_METHOD = ( + "requested_chat_id_matches_provider_chat_id" +) + + +def provider_destination_context( + *, + destination_binding: str = DESTINATION_BINDING, + provider_destination_binding: str = PROVIDER_DESTINATION_BINDING, +) -> dict[str, object]: + return { + "destination_binding": destination_binding, + "provider_destination_binding": provider_destination_binding, + "provider_destination_verification_method": ( + PROVIDER_DESTINATION_VERIFICATION_METHOD + ), + "provider_destination_identity_verified": True, + "destination_binding_verified": True, + } + def payload() -> dict: return { @@ -356,7 +378,7 @@ def test_lifecycle_endpoint_202_retry_is_db_only_and_returns_200( monkeypatch, ) -> None: monkeypatch.setattr(settings, "AGENT99_SRE_ALERT_RELAY_TOKEN", "expected") - destination_binding = "a" * 64 + destination_binding = DESTINATION_BINDING calls = {"deliver": 0, "reconcile": 0} async def pending_delivery(request): @@ -397,6 +419,10 @@ def test_lifecycle_endpoint_202_retry_is_db_only_and_returns_200( "delivery_status": "sent_reused", "provider_message_id": "8124", "destination_binding": destination_binding, + "provider_destination_binding": PROVIDER_DESTINATION_BINDING, + "provider_destination_verification_method": ( + PROVIDER_DESTINATION_VERIFICATION_METHOD + ), "durable_outbound_acknowledged": True, "destination_binding_verified": True, "provider_send_performed": False, @@ -421,6 +447,14 @@ def test_lifecycle_endpoint_202_retry_is_db_only_and_returns_200( assert first.status_code == 202 assert first.headers["retry-after"] == "2" + assert set(first.json()["reconcile_receipt"]) == { + "schema_version", + "delivery_id", + "incident_id", + "state_key", + "provider_message_id", + "destination_binding", + } retry_payload = { **payload(), "reconcile_receipt": first.json()["reconcile_receipt"], @@ -508,12 +542,17 @@ async def test_lifecycle_durable_readback_binds_exact_identity_without_egress( monkeypatch, ) -> None: gateway = object.__new__(gateway_service.TelegramGateway) - destination_binding = "a" * 64 + destination_binding = DESTINATION_BINDING reconcile = AsyncMock( return_value={ "status": "sent", "provider_message_id": "8124", "destination_binding": destination_binding, + "provider_destination_binding": PROVIDER_DESTINATION_BINDING, + "provider_destination_verification_method": ( + PROVIDER_DESTINATION_VERIFICATION_METHOD + ), + "provider_destination_identity_verified": True, "visual_requested": True, } ) @@ -529,6 +568,10 @@ async def test_lifecycle_durable_readback_binds_exact_identity_without_egress( state_key=payload()["state_key"], expected_provider_message_id="8124", expected_destination_binding=destination_binding, + expected_provider_destination_binding=PROVIDER_DESTINATION_BINDING, + expected_provider_destination_verification_method=( + PROVIDER_DESTINATION_VERIFICATION_METHOD + ), ) assert result["ok"] is True @@ -537,6 +580,11 @@ async def test_lifecycle_durable_readback_binds_exact_identity_without_egress( assert result["_awooop_durable_readback_performed"] is True assert result["_awooop_delivery_context"] == { "destination_binding": destination_binding, + "provider_destination_binding": PROVIDER_DESTINATION_BINDING, + "provider_destination_verification_method": ( + PROVIDER_DESTINATION_VERIFICATION_METHOD + ), + "provider_destination_identity_verified": True, "destination_binding_verified": True, "durable_exact_row_readback": True, } @@ -550,6 +598,10 @@ async def test_lifecycle_durable_readback_binds_exact_identity_without_egress( }, expected_provider_message_id="8124", expected_destination_binding=destination_binding, + expected_provider_destination_binding=PROVIDER_DESTINATION_BINDING, + expected_provider_destination_verification_method=( + PROVIDER_DESTINATION_VERIFICATION_METHOD + ), ) @@ -588,6 +640,10 @@ async def test_lifecycle_reconcile_destination_mismatch_never_finalizes( }, expected_provider_message_id="8124", expected_destination_binding="b" * 64, + expected_provider_destination_binding=PROVIDER_DESTINATION_BINDING, + expected_provider_destination_verification_method=( + PROVIDER_DESTINATION_VERIFICATION_METHOD + ), ) assert result["status"] == "destination_binding_mismatch" @@ -598,7 +654,7 @@ async def test_lifecycle_reconcile_destination_mismatch_never_finalizes( async def test_lifecycle_reconcile_service_never_enters_provider_sender( monkeypatch, ) -> None: - destination_binding = "a" * 64 + destination_binding = DESTINATION_BINDING calls: list[dict] = [] class Gateway: @@ -614,8 +670,9 @@ async def test_lifecycle_reconcile_service_never_enters_provider_sender( "_awooop_delivery_status": "sent_reused", "_awooop_provider_send_performed": False, "_awooop_delivery_context": { - "destination_binding": destination_binding, - "destination_binding_verified": True, + **provider_destination_context( + destination_binding=destination_binding + ), "durable_exact_row_readback": True, }, } @@ -672,9 +729,7 @@ async def test_lifecycle_service_uses_durable_canonical_sender(monkeypatch) -> N "_awooop_outbound_mirror_acknowledged": True, "_awooop_delivery_status": "sent", "_awooop_provider_send_performed": True, - "_awooop_delivery_context": { - "destination_binding_verified": True, - }, + "_awooop_delivery_context": provider_destination_context(), } monkeypatch.setattr( @@ -696,6 +751,49 @@ async def test_lifecycle_service_uses_durable_canonical_sender(monkeypatch) -> N assert "raw_log" not in calls[0]["text"] +@pytest.mark.asyncio +async def test_lifecycle_repeat_after_success_reuses_provider_pinned_receipt( + monkeypatch, +) -> None: + calls = 0 + + class Gateway: + async def send_agent99_lifecycle_receipt(self, **_kwargs): + nonlocal calls + calls += 1 + return { + "ok": True, + "result": {"message_id": 9123}, + "_awooop_outbound_mirror_acknowledged": True, + "_awooop_delivery_status": ( + "sent" if calls == 1 else "sent_reused" + ), + "_awooop_provider_send_performed": calls == 1, + "_awooop_delivery_context": provider_destination_context(), + } + + monkeypatch.setattr( + lifecycle_service, + "get_telegram_gateway", + lambda: Gateway(), + ) + request = Agent99TelegramLifecycleRequest.model_validate(payload()) + + first = await lifecycle_service.deliver_agent99_telegram_lifecycle(request) + repeated = await lifecycle_service.deliver_agent99_telegram_lifecycle( + request + ) + + assert first["ok"] is True + assert first["provider_send_performed"] is True + assert repeated["ok"] is True + assert repeated["delivery_status"] == "sent_reused" + assert repeated["provider_send_performed"] is False + assert repeated["provider_destination_binding"] == ( + PROVIDER_DESTINATION_BINDING + ) + + @pytest.mark.asyncio async def test_lifecycle_reconciles_pending_send_without_duplicate_provider_message( monkeypatch, @@ -710,10 +808,7 @@ async def test_lifecycle_reconciles_pending_send_without_duplicate_provider_mess "_awooop_delivery_status": "pending_unknown", "_awooop_provider_send_performed": True, "_awooop_visual_delivery_verified": True, - "_awooop_delivery_context": { - "destination_binding_verified": True, - "destination_binding": "a" * 64, - }, + "_awooop_delivery_context": provider_destination_context(), } reconciled_response = { "ok": True, @@ -722,6 +817,7 @@ async def test_lifecycle_reconciles_pending_send_without_duplicate_provider_mess "_awooop_delivery_status": "sent_reused", "_awooop_provider_send_performed": False, "_awooop_visual_delivery_verified": True, + "_awooop_delivery_context": provider_destination_context(), } class Gateway: @@ -765,7 +861,13 @@ async def test_lifecycle_reconciles_pending_send_without_duplicate_provider_mess "state_key": payload()["state_key"], "project_id": "awoooi", "expected_provider_message_id": "9126", - "expected_destination_binding": "a" * 64, + "expected_destination_binding": DESTINATION_BINDING, + "expected_provider_destination_binding": ( + PROVIDER_DESTINATION_BINDING + ), + "expected_provider_destination_verification_method": ( + PROVIDER_DESTINATION_VERIFICATION_METHOD + ), } @@ -787,10 +889,7 @@ async def test_lifecycle_reconciles_provider_sent_pending_ack_without_resend( "provider_sent_durable_receipt_pending" ), "_awooop_provider_send_performed": True, - "_awooop_delivery_context": { - "destination_binding_verified": True, - "destination_binding": "a" * 64, - }, + "_awooop_delivery_context": provider_destination_context(), } async def reconcile_agent99_lifecycle_receipt(self, **kwargs): @@ -801,9 +900,7 @@ async def test_lifecycle_reconciles_provider_sent_pending_ack_without_resend( "_awooop_outbound_mirror_acknowledged": True, "_awooop_delivery_status": "sent_reused", "_awooop_provider_send_performed": False, - "_awooop_delivery_context": { - "destination_binding_verified": True, - }, + "_awooop_delivery_context": provider_destination_context(), } async def no_sleep(_delay: float) -> None: @@ -827,7 +924,20 @@ async def test_lifecycle_reconciles_provider_sent_pending_ack_without_resend( assert len(send_calls) == 1 assert len(reconcile_calls) == 1 assert reconcile_calls[0]["expected_provider_message_id"] == "9129" - assert reconcile_calls[0]["expected_destination_binding"] == "a" * 64 + assert ( + reconcile_calls[0]["expected_destination_binding"] + == DESTINATION_BINDING + ) + assert ( + reconcile_calls[0]["expected_provider_destination_binding"] + == PROVIDER_DESTINATION_BINDING + ) + assert ( + reconcile_calls[0][ + "expected_provider_destination_verification_method" + ] + == PROVIDER_DESTINATION_VERIFICATION_METHOD + ) @pytest.mark.asyncio @@ -856,7 +966,7 @@ async def test_lifecycle_follower_only_reads_inflight_durable_delivery( "_awooop_delivery_status": "sent_reused", "_awooop_provider_send_performed": False, "_awooop_delivery_context": { - "destination_binding_verified": True, + **provider_destination_context(), "durable_exact_row_readback": True, }, } @@ -882,6 +992,15 @@ async def test_lifecycle_follower_only_reads_inflight_durable_delivery( assert len(reconcile_calls) == 1 assert reconcile_calls[0]["expected_provider_message_id"] is None assert reconcile_calls[0]["expected_destination_binding"] is None + assert ( + reconcile_calls[0]["expected_provider_destination_binding"] is None + ) + assert ( + reconcile_calls[0][ + "expected_provider_destination_verification_method" + ] + is None + ) @pytest.mark.asyncio @@ -901,10 +1020,7 @@ async def test_lifecycle_reconciliation_fails_closed_after_bounded_readbacks( "_awooop_outbound_mirror_acknowledged": False, "_awooop_delivery_status": "pending_unknown", "_awooop_provider_send_performed": True, - "_awooop_delivery_context": { - "destination_binding_verified": True, - "destination_binding": "a" * 64, - }, + "_awooop_delivery_context": provider_destination_context(), } async def reconcile_agent99_lifecycle_receipt(self, **kwargs): @@ -946,7 +1062,17 @@ async def test_lifecycle_reconciliation_fails_closed_after_bounded_readbacks( for call in reconcile_calls ) assert all( - call["expected_destination_binding"] == "a" * 64 + call["expected_destination_binding"] == DESTINATION_BINDING + for call in reconcile_calls + ) + assert all( + call["expected_provider_destination_binding"] + == PROVIDER_DESTINATION_BINDING + for call in reconcile_calls + ) + assert all( + call["expected_provider_destination_verification_method"] + == PROVIDER_DESTINATION_VERIFICATION_METHOD for call in reconcile_calls ) @@ -965,9 +1091,7 @@ async def test_visual_lifecycle_uses_one_durable_send_photo_receipt(monkeypatch) "_awooop_delivery_status": "sent", "_awooop_provider_send_performed": True, "_awooop_visual_delivery_verified": True, - "_awooop_delivery_context": { - "destination_binding_verified": True, - }, + "_awooop_delivery_context": provider_destination_context(), } monkeypatch.setattr( @@ -1016,7 +1140,7 @@ async def test_reused_legacy_text_receipt_does_not_claim_visual_delivery( receipt = await lifecycle_service.deliver_agent99_telegram_lifecycle(request) - assert receipt["ok"] is True + assert receipt["ok"] is False assert receipt["visual_requested"] is True assert receipt["visual_sent"] is False assert receipt["visual_delivery_status"] == "sent_reused" diff --git a/apps/api/tests/test_ansible_verified_closure.py b/apps/api/tests/test_ansible_verified_closure.py index 11c7fdd1d..8ad52fad1 100644 --- a/apps/api/tests/test_ansible_verified_closure.py +++ b/apps/api/tests/test_ansible_verified_closure.py @@ -15,6 +15,12 @@ from src.services import telegram_gateway as telegram_gateway_module from src.services.telegram_gateway import TelegramGateway from src.workers import ansible_executor_broker as broker +DESTINATION_BINDING = "a" * 64 +PROVIDER_DESTINATION_BINDING = "b" * 64 +PROVIDER_DESTINATION_VERIFICATION_METHOD = ( + "requested_chat_id_matches_provider_chat_id" +) + class _MappingResult: def __init__(self, row: dict | None = None, scalar=None) -> None: @@ -867,16 +873,24 @@ async def test_durable_reconcile_finalizes_exact_pending_row_without_provider_se "message_id": "00000000-0000-0000-0000-000000000202", "send_status": "pending", "provider_message_id": None, + "destination_binding": DESTINATION_BINDING, "visual_requested": True, } sent_row = { **pending_row, "send_status": "sent", "provider_message_id": "456", + "destination_binding": DESTINATION_BINDING, + "provider_destination_binding": PROVIDER_DESTINATION_BINDING, + "provider_destination_verification_method": ( + PROVIDER_DESTINATION_VERIFICATION_METHOD + ), + "provider_destination_identity_verified": True, } db = _SequenceDB( _MappingResult(row=pending_row), _MappingResult(), + _MappingResult(), _MappingResult( row={"send_status": "sent", "provider_message_id": "456"} ), @@ -901,6 +915,11 @@ async def test_durable_reconcile_finalizes_exact_pending_row_without_provider_se result = await gateway._reconcile_controlled_apply_result_outbound( identity=identity, expected_provider_message_id="456", + expected_destination_binding=DESTINATION_BINDING, + expected_provider_destination_binding=PROVIDER_DESTINATION_BINDING, + expected_provider_destination_verification_method=( + PROVIDER_DESTINATION_VERIFICATION_METHOD + ), ) assert result == { @@ -912,15 +931,27 @@ async def test_durable_reconcile_finalizes_exact_pending_row_without_provider_se ), "message_id": pending_row["message_id"], "provider_message_id": "456", + "destination_binding": DESTINATION_BINDING, + "provider_destination_binding": PROVIDER_DESTINATION_BINDING, + "provider_destination_verification_method": ( + PROVIDER_DESTINATION_VERIFICATION_METHOD + ), + "provider_destination_identity_verified": True, "visual_requested": True, } gateway._http_client.post.assert_not_awaited() - assert len(db.statements) == 4 + assert len(db.statements) == 5 assert "{callback_reply,automation_run_id}" in db.statements[0] + assert "provider_destination_binding" in db.statements[0] + assert "provider_destination_verification_method" in db.statements[0] + assert "provider_destination_identity_verified" in db.statements[0] assert db.parameters[0]["automation_run_id"] == identity["automation_run_id"] assert db.parameters[0]["incident_id"] == identity["incident_id"] assert db.parameters[0]["apply_op_id"] == identity["apply_op_id"] - assert "UPDATE awooop_outbound_message" in db.statements[2] + assert "telegram-destination-pin:" in db.parameters[2][ + "destination_lock_key" + ] + assert "UPDATE awooop_outbound_message" in db.statements[3] @pytest.mark.asyncio @@ -928,6 +959,7 @@ async def test_controlled_result_finalize_backfills_missing_destination_binding( monkeypatch: pytest.MonkeyPatch, ) -> None: db = _SequenceDB( + _MappingResult(), _MappingResult(), _MappingResult(row={"send_status": "sent", "provider_message_id": "456"}), ) @@ -957,11 +989,15 @@ async def test_controlled_result_finalize_backfills_missing_destination_binding( ), }, provider_message_id="456", - expected_destination_binding="a" * 64, + expected_destination_binding=DESTINATION_BINDING, + provider_destination_binding=PROVIDER_DESTINATION_BINDING, + provider_destination_verification_method=( + PROVIDER_DESTINATION_VERIFICATION_METHOD + ), ) assert finalized is True - update_sql = db.statements[1] + update_sql = db.statements[2] assert "{canonical_route_receipt,destination_binding}" in update_sql assert "COALESCE(" in update_sql assert "IN ('', :expected_destination_binding)" in update_sql @@ -969,7 +1005,17 @@ async def test_controlled_result_finalize_backfills_missing_destination_binding( assert ":expected_destination_binding\n" in update_sql assert "AS text" in update_sql assert ":expected_destination_binding::text" not in update_sql - assert db.parameters[1]["expected_destination_binding"] == "a" * 64 + assert "NOT EXISTS" in update_sql + assert "provider_destination_binding" in update_sql + assert db.parameters[2]["expected_destination_binding"] == ( + DESTINATION_BINDING + ) + assert db.parameters[2]["provider_destination_binding"] == ( + PROVIDER_DESTINATION_BINDING + ) + assert db.parameters[2][ + "provider_destination_verification_method" + ] == PROVIDER_DESTINATION_VERIFICATION_METHOD @pytest.mark.asyncio @@ -1026,6 +1072,9 @@ async def test_controlled_result_reuses_durable_sent_reservation( gateway._initialized = True client = AsyncMock() gateway._http_client = client + destination_binding = ( + telegram_gateway_module._telegram_destination_binding("sre-chat") + ) monkeypatch.setattr( gateway, "_reserve_controlled_apply_result_outbound", @@ -1035,6 +1084,12 @@ async def test_controlled_result_reuses_durable_sent_reservation( "run_id": "00000000-0000-0000-0000-000000000201", "message_id": "00000000-0000-0000-0000-000000000202", "provider_message_id": "456", + "destination_binding": destination_binding, + "provider_destination_binding": destination_binding, + "provider_destination_verification_method": ( + PROVIDER_DESTINATION_VERIFICATION_METHOD + ), + "provider_destination_identity_verified": True, } ), ) @@ -1047,6 +1102,16 @@ async def test_controlled_result_reuses_durable_sent_reservation( assert result["ok"] is True assert result["_awooop_outbound_mirror_acknowledged"] is True assert result["_awooop_provider_send_performed"] is False + assert result["_awooop_delivery_context"] == { + "destination_binding": destination_binding, + "provider_destination_binding": destination_binding, + "provider_destination_verification_method": ( + PROVIDER_DESTINATION_VERIFICATION_METHOD + ), + "provider_destination_identity_verified": True, + "destination_binding_verified": True, + "durable_exact_row_readback": True, + } client.post.assert_not_awaited() diff --git a/apps/api/tests/test_telegram_canonical_sender_gate.py b/apps/api/tests/test_telegram_canonical_sender_gate.py index cabdca3f4..642104f23 100644 --- a/apps/api/tests/test_telegram_canonical_sender_gate.py +++ b/apps/api/tests/test_telegram_canonical_sender_gate.py @@ -785,6 +785,206 @@ async def test_provider_chat_mismatch_is_non_terminal_delivery( assert len(client.posts) == 1 +@pytest.mark.asyncio +async def test_provider_username_resolution_preserves_verified_destination( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway, client = _prepared_gateway(monkeypatch) + monkeypatch.setattr( + gateway_module.settings, + "SRE_GROUP_CHAT_ID", + "@AwoooiSRE", + ) + monkeypatch.setattr( + _FakeResponse, + "json", + lambda _self: { + "ok": True, + "result": { + "message_id": 11, + "chat": {"id": -1001234567890, "username": "awoooisre"}, + }, + }, + ) + + result = await gateway.send_escalation_card( + incident_id="INC-TEST", + original_alertname="Outage", + duration_min=15, + ) + + context = result[gateway_module._DELIVERY_CONTEXT_KEY] + assert result["_awooop_delivery_status"] == "sent" + assert context["destination_binding_verified"] is True + assert context["provider_destination_identity_verified"] is True + assert context["provider_destination_binding"] != context["destination_binding"] + assert gateway_module._telegram_send_delivery_succeeded(result) is True + assert len(client.posts) == 1 + + +@pytest.mark.asyncio +async def test_provider_username_proof_survives_lifecycle_restart_readback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway, client = _prepared_gateway(monkeypatch) + monkeypatch.setattr( + gateway_module.settings, + "SRE_GROUP_CHAT_ID", + "@AwoooiSRE", + ) + monkeypatch.setattr( + _FakeResponse, + "json", + lambda _self: { + "ok": True, + "result": { + "message_id": 8124, + "chat": {"id": -1001234567890, "username": "awoooisre"}, + }, + }, + ) + reserve = AsyncMock( + return_value={ + "status": "reserved", + "run_id": "00000000-0000-0000-0000-000000000201", + "message_id": "00000000-0000-0000-0000-000000000202", + "durable_reservation_committed": True, + } + ) + finalize = AsyncMock(return_value=True) + monkeypatch.setattr( + gateway, + "_reserve_controlled_apply_result_outbound", + reserve, + ) + monkeypatch.setattr( + gateway, + "_finalize_controlled_apply_result_outbound", + finalize, + ) + + delivered = await gateway.send_agent99_lifecycle_receipt( + delivery_id=( + "agent99-lifecycle-0123456789abcdef0123456789abcdef01234567" + ), + incident_id="INC-20260718-ALIAS", + state_key="recovered|info", + text="Agent99 verified lifecycle", + priority="P1", + ) + + delivery_context = delivered[gateway_module._DELIVERY_CONTEXT_KEY] + assert delivered["_awooop_delivery_status"] == "sent" + assert delivery_context["destination_binding_verified"] is True + assert delivery_context["provider_destination_binding"] != ( + delivery_context["destination_binding"] + ) + finalize.assert_awaited_once() + finalize_kwargs = finalize.await_args.kwargs + assert finalize_kwargs["expected_destination_binding"] == ( + delivery_context["destination_binding"] + ) + assert finalize_kwargs["provider_destination_binding"] == ( + delivery_context["provider_destination_binding"] + ) + assert finalize_kwargs["provider_destination_verification_method"] == ( + "requested_username_matches_provider_username" + ) + assert len(client.posts) == 1 + + restarted_gateway = object.__new__(TelegramGateway) + durable_readback = AsyncMock( + return_value={ + "status": "sent", + "provider_message_id": "8124", + "destination_binding": delivery_context["destination_binding"], + "provider_destination_binding": delivery_context[ + "provider_destination_binding" + ], + "provider_destination_verification_method": ( + "requested_username_matches_provider_username" + ), + "provider_destination_identity_verified": True, + "visual_requested": False, + } + ) + monkeypatch.setattr( + restarted_gateway, + "_reconcile_controlled_apply_result_outbound", + durable_readback, + ) + + reconciled = await restarted_gateway.reconcile_agent99_lifecycle_receipt( + delivery_id=( + "agent99-lifecycle-0123456789abcdef0123456789abcdef01234567" + ), + incident_id="INC-20260718-ALIAS", + state_key="recovered|info", + expected_provider_message_id="8124", + expected_destination_binding=delivery_context["destination_binding"], + expected_provider_destination_binding=delivery_context[ + "provider_destination_binding" + ], + expected_provider_destination_verification_method=( + "requested_username_matches_provider_username" + ), + ) + + assert reconciled["ok"] is True + assert reconciled["_awooop_delivery_status"] == "sent_reused" + assert reconciled["_awooop_delivery_context"][ + "destination_binding_verified" + ] is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("provider_chat", "expected_status"), + [ + ( + {"id": -1001234567890, "username": "different_room"}, + "provider_destination_mismatch", + ), + ({"username": "awoooisre"}, "provider_destination_unverified"), + ], +) +async def test_provider_username_resolution_fails_closed_on_identity_gap( + monkeypatch: pytest.MonkeyPatch, + provider_chat: dict[str, object], + expected_status: str, +) -> None: + gateway, client = _prepared_gateway(monkeypatch) + monkeypatch.setattr( + gateway_module.settings, + "SRE_GROUP_CHAT_ID", + "@AwoooiSRE", + ) + monkeypatch.setattr( + _FakeResponse, + "json", + lambda _self: { + "ok": True, + "result": {"message_id": 11, "chat": provider_chat}, + }, + ) + + result = await gateway.send_escalation_card( + incident_id="INC-TEST", + original_alertname="Outage", + duration_min=15, + ) + + assert result["_awooop_delivery_status"] == expected_status + assert ( + result[gateway_module._DELIVERY_CONTEXT_KEY][ + "destination_binding_verified" + ] + is False + ) + assert gateway_module._telegram_send_delivery_succeeded(result) is False + assert len(client.posts) == 1 + + @pytest.mark.asyncio async def test_openclaw_cannot_own_tsenyang_monitoring_route( monkeypatch: pytest.MonkeyPatch,