Files
awoooi/apps/api/tests/test_telegram_callback_ingress_contract.py
ogt fc8ac4e602
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 1s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 5m59s
E2E Health Check / e2e-health (push) Failing after 32s
CD Pipeline / build-and-deploy (push) Successful in 15m0s
AWOOOI Harbor 110 Local Repair / workflow-shape (push) Successful in 0s
AWOOOI Harbor 110 Local Repair / harbor-110-local-repair (push) Successful in 12s
CD Pipeline / post-deploy-checks (push) Successful in 5m49s
feat(telegram): accept signed callback forwards
2026-07-16 23:52:02 +08:00

554 lines
18 KiB
Python

from __future__ import annotations
import hashlib
import hmac
import json
import time
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from src.api.v1 import telegram as telegram_api
from src.api.v1 import telegram_webhook as telegram_webhook_api
from src.services import telegram_gateway as telegram_gateway_module
from src.services.callback_dispatcher import load_action_registry
from src.services.telegram_button_registry import (
callback_action_is_registered,
registered_info_callback_actions,
resolve_callback_action_contract,
telegram_button_registry_snapshot,
)
from src.services.telegram_gateway import (
CALLBACK_INGRESS_RECEIPT_MAX_AGE_SECONDS,
TelegramGateway,
filter_telegram_reply_markup,
)
class _ConfiguredGateway(TelegramGateway):
@property
def bot_token(self) -> str:
return "test-configured"
@property
def chat_id(self) -> str:
return "test-configured"
class _Security:
@staticmethod
def generate_callback_nonce(approval_id: str, action: str) -> str:
return f"{action}:{approval_id}:1:nonce"
def _callback_rows(markup: dict | None) -> list[dict]:
return [
button
for row in (markup or {}).get("inline_keyboard", [])
for button in row
]
def test_callback_ingress_defaults_to_unverified_fail_closed() -> None:
gateway = _ConfiguredGateway()
status = gateway.callback_ingress_status()
assert status["mode"] == "external_callback_forward_unverified"
assert status["polling_active"] is False
assert status["receipt_available"] is False
assert status["interactive_buttons_ready"] is False
assert status["fail_closed_reason"] == "callback_ingress_not_verified"
def test_callback_ingress_receipt_has_five_minute_freshness_contract() -> None:
gateway = _ConfiguredGateway()
assert CALLBACK_INGRESS_RECEIPT_MAX_AGE_SECONDS == 5 * 60
gateway._last_callback_ingress_source = "canonical_webhook"
gateway._last_callback_ingress_receipt_durable = True
gateway._last_callback_ingress_receipt_at = datetime.now(UTC) - timedelta(minutes=4)
fresh = gateway.callback_ingress_status()
assert fresh["receipt_fresh"] is True
assert fresh["interactive_buttons_ready"] is True
gateway._last_callback_ingress_receipt_at = datetime.now(UTC) - timedelta(minutes=6)
stale = gateway.callback_ingress_status()
assert stale["stale"] is True
assert stale["interactive_buttons_ready"] is False
class _SharedReceiptRedis:
def __init__(self) -> None:
self.value: str | None = None
async def setex(self, _key: str, _ttl: int, value: str) -> None:
self.value = value
async def get(self, _key: str) -> str | None:
return self.value
@pytest.mark.asyncio
async def test_callback_ingress_receipt_is_shared_across_gateway_processes(
monkeypatch,
) -> None:
redis = _SharedReceiptRedis()
monkeypatch.setattr(telegram_gateway_module, "get_redis", lambda: redis)
writer = _ConfiguredGateway()
reader = _ConfiguredGateway()
await writer.record_callback_ingress_receipt(
source="canonical_webhook",
update_id=88,
)
assert await reader.refresh_callback_ingress_receipt() is True
status = reader.callback_ingress_status()
assert status["receipt_durable"] is True
assert status["receipt_fresh"] is True
assert status["interactive_buttons_ready"] is True
assert reader._last_update_id == 88
def test_polling_flag_without_live_task_is_stale_not_ready() -> None:
gateway = _ConfiguredGateway()
gateway._polling_active = True
gateway._polling_task = None
status = gateway.callback_ingress_status()
assert status["mode"] == "api_long_polling_stale"
assert status["polling_declared_active"] is True
assert status["polling_task_active"] is False
assert status["polling_active"] is False
assert status["interactive_buttons_ready"] is False
def test_fail_closed_markup_keeps_only_verified_https_urls() -> None:
markup = {
"inline_keyboard": [[
{"text": "詳情", "callback_data": "detail:INC-20260716-TEST"},
{"text": "Runs", "url": "https://awoooi.wooo.work/zh-TW/awooop/runs"},
{"text": "Unsafe", "url": "http://192.0.2.1/internal"},
{"text": "External", "url": "https://example.com/not-canonical"},
]]
}
filtered = filter_telegram_reply_markup(
markup,
interactive_buttons_ready=False,
)
assert _callback_rows(filtered) == [{
"text": "Runs",
"url": "https://awoooi.wooo.work/zh-TW/awooop/runs",
}]
def test_gateway_render_gate_uses_live_ingress_status() -> None:
gateway = _ConfiguredGateway()
payload = {
"text": "incident",
"reply_markup": {
"inline_keyboard": [[
{"text": "詳情", "callback_data": "detail:INC-20260716-TEST"},
{"text": "Runs", "url": "https://awoooi.wooo.work/zh-TW/awooop/runs"},
]]
},
}
guarded = gateway._apply_callback_ingress_render_gate(payload)
assert _callback_rows(guarded["reply_markup"]) == [{
"text": "Runs",
"url": "https://awoooi.wooo.work/zh-TW/awooop/runs",
}]
def test_ready_markup_still_removes_unknown_ghost_callbacks() -> None:
markup = {
"inline_keyboard": [[
{"text": "詳情", "callback_data": "detail:INC-20260716-TEST"},
{"text": "Ghost view", "callback_data": "view:ERR:1:nonce"},
{"text": "Ghost scale", "callback_data": "scale:RES:1:nonce"},
{"text": "Runs", "url": "https://awoooi.wooo.work/zh-TW/awooop/runs"},
]]
}
filtered = filter_telegram_reply_markup(
markup,
interactive_buttons_ready=True,
)
rendered = _callback_rows(filtered)
assert {button.get("callback_data") for button in rendered} == {
"detail:INC-20260716-TEST",
None,
}
assert all("view:" not in str(button) for button in rendered)
assert all("scale:" not in str(button) for button in rendered)
def test_machine_registry_excludes_ghosts_and_fails_closed_write_actions() -> None:
assert callback_action_is_registered("detail:INC-20260716-TEST") is True
assert callback_action_is_registered("view:ERR:1:nonce") is False
assert callback_action_is_registered("scale:RES:1:nonce") is False
assert resolve_callback_action_contract("tune:APR-1:1:nonce") is None
assert resolve_callback_action_contract("approve:APR-1:1:nonce") is None
snapshot = telegram_button_registry_snapshot()
action_names = {row["action"] for row in snapshot["actions"]}
assert snapshot["schema_version"] == "telegram_button_action_registry_v1"
assert snapshot["unknown_action_policy"] == "do_not_render"
assert snapshot["write_action_policy"] == (
"fail_closed_until_controlled_apply_contract"
)
assert "view" not in action_names
assert "scale" not in action_names
actions = {row["action"]: row for row in snapshot["actions"]}
assert actions["tune"]["state"] == "shadow_candidate_only"
assert actions["approve"]["state"] == (
"blocked_missing_controlled_apply_contract"
)
assert actions["secops_block_ip"]["state"] == "break_glass_only"
def test_only_low_risk_read_only_yaml_actions_are_renderable() -> None:
registry = load_action_registry()
assert registry
for action, spec in registry.items():
callback_data = (
f"{action}:APR-1:1:nonce"
if spec.callback_format == "nonce"
else f"{action}:INC-20260716-TEST"
)
contract = resolve_callback_action_contract(callback_data)
if spec.callback_format == "info" and spec.risk == "low":
assert contract is not None, action
assert contract.handler == "callback_dispatcher.dispatch_action"
assert contract.executor.startswith("mcp:")
else:
assert contract is None, action
def test_security_parser_accepts_every_registered_info_action() -> None:
gateway = _ConfiguredGateway()
info_actions = registered_info_callback_actions()
assert {"detail", "history", "reanalyze", "drift_view_page"} <= info_actions
for action in info_actions:
parsed = gateway._security.parse_callback_data(
f"{action}:INC-20260716-TEST"
)
assert parsed["action"] == action
assert parsed["is_info_action"] is True
def test_legacy_builders_no_longer_emit_view_or_scale() -> None:
gateway = _ConfiguredGateway()
gateway._security = _Security()
sentry = _callback_rows(gateway._build_sentry_keyboard("ERR-1"))
resource = _callback_rows(gateway._build_resource_keyboard("RES-1"))
assert [button["callback_data"].split(":", 1)[0] for button in sentry] == [
"silence"
]
assert [button["callback_data"].split(":", 1)[0] for button in resource] == [
"silence"
]
@pytest.mark.asyncio
async def test_tune_button_truthfully_says_it_records_a_candidate() -> None:
gateway = _ConfiguredGateway()
gateway._security = _Security()
markup = await gateway._build_inline_keyboard(
approval_id="APR-1",
incident_id="INC-20260716-TEST",
include_auto_tuning=True,
auto_tuning_command="candidate-only",
)
labels = {button["text"] for button in _callback_rows(markup)}
assert "📝 記錄調優候選" in labels
assert "⚡ 執行自動調優" not in labels
class _InfoCallbackGateway(_ConfiguredGateway):
def __init__(self) -> None:
super().__init__()
self.approval_execution_called = False
async def mirror_callback_query_received(self, **_kwargs) -> str:
return "receipt-1"
async def handle_callback(self, **_kwargs) -> dict:
return {
"success": True,
"info_action": True,
"action": "detail",
"approval_id": "INC-20260716-TEST",
}
async def _execute_approval_action(self, **_kwargs) -> None:
self.approval_execution_called = True
@pytest.mark.asyncio
async def test_polling_info_action_does_not_execute_approval_stage() -> None:
gateway = _InfoCallbackGateway()
await gateway._handle_callback_query(
42,
{
"id": "callback-1",
"data": "detail:INC-20260716-TEST",
"from": {"id": 7, "username": "operator"},
"message": {
"message_id": 99,
"text": "incident",
"chat": {"id": -1001},
},
},
)
assert gateway.approval_execution_called is False
assert gateway.callback_ingress_status()["receipt_source"] == "api_long_polling"
def test_webhook_route_has_one_canonical_owner() -> None:
route_paths = [route.path for route in telegram_api.router.routes]
route_paths.extend(route.path for route in telegram_webhook_api.router.routes)
assert route_paths.count("/telegram/webhook") == 1
def test_production_webhook_without_secret_fails_closed(monkeypatch) -> None:
monkeypatch.setattr(
telegram_webhook_api,
"settings",
SimpleNamespace(ENVIRONMENT="prod", TELEGRAM_WEBHOOK_SECRET=""),
)
with pytest.raises(HTTPException) as exc_info:
telegram_webhook_api._verify_secret_token(None)
assert exc_info.value.status_code == 503
class _CallbackRequest:
async def json(self) -> dict:
return {
"update_id": 77,
"callback_query": {
"id": "callback-77",
"data": "detail:INC-20260716-TEST",
"from": {"id": 7, "username": "operator"},
"message": {"message_id": 9, "chat": {"id": -1001}},
},
}
class _CanonicalWebhookGateway:
def __init__(self) -> None:
self.receipts: list[dict] = []
async def _handle_callback_query(
self,
update_id: int,
callback_query: dict,
*,
ingress_source: str,
) -> None:
self.receipts.append({
"update_id": update_id,
"callback_query": callback_query,
"ingress_source": ingress_source,
})
class _CallbackForwardRequest:
def __init__(self, body: bytes) -> None:
self._body = body
async def body(self) -> bytes:
return self._body
class _CallbackForwardRedis:
def __init__(self) -> None:
self.values: dict[str, str] = {}
async def set(self, key: str, value: str, **_kwargs) -> bool:
if key in self.values:
return False
self.values[key] = value
return True
async def setex(self, key: str, _ttl: int, value: str) -> None:
self.values[key] = value
async def delete(self, key: str) -> None:
self.values.pop(key, None)
def _signed_callback_forward_body(token: str) -> tuple[bytes, str, str]:
body = json.dumps(
{
"callback_query": {
"data": "detail:INC-20260716-TEST",
"from": {"id": 7, "username": "operator"},
"id": "callback-forward-77",
"message": {
"chat": {"id": -1001},
"message_id": 9,
"text": "incident",
},
},
"update_id": 77,
},
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
timestamp = str(int(time.time()))
signature = "sha256=" + hmac.new(
token.encode("utf-8"),
timestamp.encode("ascii") + b"." + body,
hashlib.sha256,
).hexdigest()
return body, timestamp, signature
@pytest.mark.asyncio
async def test_hmac_callback_forward_processes_only_registered_info_action(
monkeypatch,
) -> None:
token = "test-forwarder-token"
body, timestamp, signature = _signed_callback_forward_body(token)
gateway = _CanonicalWebhookGateway()
redis = _CallbackForwardRedis()
monkeypatch.setattr(
telegram_webhook_api,
"settings",
SimpleNamespace(OPENCLAW_TG_BOT_TOKEN=token),
)
monkeypatch.setattr(
telegram_gateway_module,
"get_telegram_gateway",
lambda: gateway,
)
monkeypatch.setattr("src.core.redis_client.get_redis", lambda: redis)
result = await telegram_webhook_api.telegram_callback_forward(
_CallbackForwardRequest(body),
x_telegram_forward_timestamp=timestamp,
x_telegram_forward_signature=signature,
)
assert result == {
"ok": True,
"status": "processed",
"update_id": 77,
"action": "detail",
}
assert gateway.receipts[0]["ingress_source"] == (
"openclaw_188_hmac_forwarder"
)
@pytest.mark.asyncio
async def test_hmac_callback_forward_rejects_invalid_signature(monkeypatch) -> None:
token = "test-forwarder-token"
body, timestamp, _signature = _signed_callback_forward_body(token)
monkeypatch.setattr(
telegram_webhook_api,
"settings",
SimpleNamespace(OPENCLAW_TG_BOT_TOKEN=token),
)
with pytest.raises(HTTPException) as exc_info:
await telegram_webhook_api.telegram_callback_forward(
_CallbackForwardRequest(body),
x_telegram_forward_timestamp=timestamp,
x_telegram_forward_signature="sha256=invalid",
)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_hmac_callback_forward_rejects_write_action(monkeypatch) -> None:
token = "test-forwarder-token"
body, timestamp, _signature = _signed_callback_forward_body(token)
unsafe_body = body.replace(
b"detail:INC-20260716-TEST",
b"approve:APR-1:1:nonce",
)
signature = "sha256=" + hmac.new(
token.encode("utf-8"),
timestamp.encode("ascii") + b"." + unsafe_body,
hashlib.sha256,
).hexdigest()
monkeypatch.setattr(
telegram_webhook_api,
"settings",
SimpleNamespace(OPENCLAW_TG_BOT_TOKEN=token),
)
with pytest.raises(HTTPException) as exc_info:
await telegram_webhook_api.telegram_callback_forward(
_CallbackForwardRequest(unsafe_body),
x_telegram_forward_timestamp=timestamp,
x_telegram_forward_signature=signature,
)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_canonical_webhook_forwards_callback_to_gateway_handler(monkeypatch) -> None:
gateway = _CanonicalWebhookGateway()
monkeypatch.setattr(
telegram_gateway_module,
"get_telegram_gateway",
lambda: gateway,
)
result = await telegram_webhook_api.telegram_webhook(_CallbackRequest())
assert result == {"ok": True}
assert len(gateway.receipts) == 1
assert gateway.receipts[0]["update_id"] == 77
assert gateway.receipts[0]["ingress_source"] == "canonical_webhook"
@pytest.mark.asyncio
async def test_health_is_degraded_when_callback_ingress_is_unverified(monkeypatch) -> None:
gateway = _ConfiguredGateway()
monkeypatch.setattr(telegram_api, "get_telegram_gateway", lambda: gateway)
monkeypatch.setattr(
telegram_api,
"settings",
SimpleNamespace(
OPENCLAW_TG_BOT_TOKEN="test-configured",
SRE_GROUP_CHAT_ID="test-configured",
OPENCLAW_TG_USER_WHITELIST=[],
ENVIRONMENT="test",
),
)
health = await telegram_api.telegram_health()
assert health["status"] == "degraded_callback_ingress_unverified"
assert health["mode"] == "external_callback_forward_unverified"
assert health["interactive_buttons_ready"] is False
assert health["callback_ingress"]["fail_closed_reason"] == (
"callback_ingress_not_verified"
)