fix(telegram): persist controlled callback action receipts
This commit is contained in:
@@ -20,7 +20,13 @@ from src.services.telegram_button_registry import (
|
||||
resolve_callback_action_contract,
|
||||
telegram_button_registry_snapshot,
|
||||
)
|
||||
from src.services.telegram_callback_correlation import (
|
||||
CONTROLLED_CALLBACK_CORRELATION_SCHEMA,
|
||||
controlled_callback_correlation_fingerprint,
|
||||
normalize_controlled_callback_correlation,
|
||||
)
|
||||
from src.services.telegram_gateway import (
|
||||
CALLBACK_CONTROLLED_ACTION_RECEIPT_KEY,
|
||||
CALLBACK_INGRESS_RECEIPT_MAX_AGE_SECONDS,
|
||||
TelegramGateway,
|
||||
filter_telegram_reply_markup,
|
||||
@@ -82,13 +88,13 @@ def test_callback_ingress_receipt_has_five_minute_freshness_contract() -> None:
|
||||
|
||||
class _SharedReceiptRedis:
|
||||
def __init__(self) -> None:
|
||||
self.value: str | None = None
|
||||
self.values: dict[str, str] = {}
|
||||
|
||||
async def setex(self, _key: str, _ttl: int, value: str) -> None:
|
||||
self.value = value
|
||||
async def setex(self, key: str, _ttl: int, value: str) -> None:
|
||||
self.values[key] = value
|
||||
|
||||
async def get(self, _key: str) -> str | None:
|
||||
return self.value
|
||||
async def get(self, key: str) -> str | None:
|
||||
return self.values.get(key)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -356,8 +362,9 @@ class _CallbackRequest:
|
||||
|
||||
|
||||
class _CanonicalWebhookGateway:
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, *, success: bool = True) -> None:
|
||||
self.receipts: list[dict] = []
|
||||
self.success = success
|
||||
|
||||
async def _handle_callback_query(
|
||||
self,
|
||||
@@ -365,12 +372,19 @@ class _CanonicalWebhookGateway:
|
||||
callback_query: dict,
|
||||
*,
|
||||
ingress_source: str,
|
||||
) -> None:
|
||||
ingress_correlation: dict | None = None,
|
||||
) -> dict:
|
||||
self.receipts.append({
|
||||
"update_id": update_id,
|
||||
"callback_query": callback_query,
|
||||
"ingress_source": ingress_source,
|
||||
"ingress_correlation": ingress_correlation,
|
||||
})
|
||||
return {
|
||||
"success": self.success,
|
||||
"action": "detail",
|
||||
"controlled_action_receipt_durable": bool(ingress_correlation),
|
||||
}
|
||||
|
||||
|
||||
class _CallbackForwardRequest:
|
||||
@@ -398,9 +412,12 @@ class _CallbackForwardRedis:
|
||||
self.values.pop(key, None)
|
||||
|
||||
|
||||
def _signed_callback_forward_body(token: str) -> tuple[bytes, str, str]:
|
||||
body = json.dumps(
|
||||
{
|
||||
def _signed_callback_forward_body(
|
||||
token: str,
|
||||
*,
|
||||
controlled_correlation: dict | None = None,
|
||||
) -> tuple[bytes, str, str]:
|
||||
payload = {
|
||||
"callback_query": {
|
||||
"data": "detail:INC-20260716-TEST",
|
||||
"from": {"id": 7, "username": "operator"},
|
||||
@@ -412,7 +429,11 @@ def _signed_callback_forward_body(token: str) -> tuple[bytes, str, str]:
|
||||
},
|
||||
},
|
||||
"update_id": 77,
|
||||
},
|
||||
}
|
||||
if controlled_correlation is not None:
|
||||
payload["controlled_correlation"] = controlled_correlation
|
||||
body = json.dumps(
|
||||
payload,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
@@ -456,12 +477,146 @@ async def test_hmac_callback_forward_processes_only_registered_info_action(
|
||||
"status": "processed",
|
||||
"update_id": 77,
|
||||
"action": "detail",
|
||||
"controlled_receipt": False,
|
||||
}
|
||||
assert gateway.receipts[0]["ingress_source"] == (
|
||||
"openclaw_188_hmac_forwarder"
|
||||
)
|
||||
|
||||
|
||||
def _controlled_correlation() -> dict[str, str]:
|
||||
return {
|
||||
"schema_version": CONTROLLED_CALLBACK_CORRELATION_SCHEMA,
|
||||
"trace_id": "trace:host188:001",
|
||||
"run_id": "run:host188:001",
|
||||
"work_item_id": "AIA-SRE-017",
|
||||
}
|
||||
|
||||
|
||||
def test_controlled_callback_correlation_is_exact_and_stably_fingerprinted() -> None:
|
||||
correlation = _controlled_correlation()
|
||||
|
||||
assert normalize_controlled_callback_correlation(correlation) == correlation
|
||||
assert len(controlled_callback_correlation_fingerprint(correlation)) == 64
|
||||
with pytest.raises(ValueError, match="shape"):
|
||||
normalize_controlled_callback_correlation({**correlation, "extra": "no"})
|
||||
with pytest.raises(ValueError, match="run_id"):
|
||||
normalize_controlled_callback_correlation({**correlation, "run_id": "unsafe id"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hmac_callback_forward_carries_controlled_receipt_without_raw_ids(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
token = "test-forwarder-token"
|
||||
correlation = _controlled_correlation()
|
||||
body, timestamp, signature = _signed_callback_forward_body(
|
||||
token,
|
||||
controlled_correlation=correlation,
|
||||
)
|
||||
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",
|
||||
"controlled_receipt": True,
|
||||
}
|
||||
assert gateway.receipts[0]["ingress_correlation"] == correlation
|
||||
assert correlation["run_id"] not in json.dumps(result)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hmac_callback_forward_does_not_finalize_failed_action(monkeypatch) -> None:
|
||||
token = "test-forwarder-token"
|
||||
body, timestamp, signature = _signed_callback_forward_body(token)
|
||||
gateway = _CanonicalWebhookGateway(success=False)
|
||||
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)
|
||||
|
||||
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=signature,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 502
|
||||
assert redis.values == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_controlled_action_receipt_is_durable_and_redacted(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
redis = _SharedReceiptRedis()
|
||||
monkeypatch.setattr(telegram_gateway_module, "get_redis", lambda: redis)
|
||||
writer = _InfoCallbackGateway()
|
||||
reader = _ConfiguredGateway()
|
||||
correlation = _controlled_correlation()
|
||||
|
||||
result = await writer._handle_callback_query(
|
||||
42,
|
||||
{
|
||||
"id": "callback-controlled-1",
|
||||
"data": "detail:INC-20260716-TEST",
|
||||
"from": {"id": 7, "username": "operator"},
|
||||
"message": {
|
||||
"message_id": 99,
|
||||
"text": "incident",
|
||||
"chat": {"id": -1001},
|
||||
},
|
||||
},
|
||||
ingress_source="openclaw_188_hmac_forwarder",
|
||||
ingress_correlation=correlation,
|
||||
)
|
||||
|
||||
assert result["controlled_action_receipt_durable"] is True
|
||||
assert CALLBACK_CONTROLLED_ACTION_RECEIPT_KEY in redis.values
|
||||
assert await reader.refresh_callback_ingress_receipt() is True
|
||||
receipt = reader.callback_ingress_status()["controlled_action_receipt"]
|
||||
assert receipt["durable"] is True
|
||||
assert receipt["fresh"] is True
|
||||
assert receipt["source"] == "openclaw_188_hmac_forwarder"
|
||||
assert receipt["action"] == "detail"
|
||||
assert receipt["correlation_fingerprint"] == (
|
||||
controlled_callback_correlation_fingerprint(correlation)
|
||||
)
|
||||
serialized = json.dumps(receipt)
|
||||
assert correlation["trace_id"] not in serialized
|
||||
assert correlation["run_id"] not in serialized
|
||||
assert correlation["work_item_id"] not in serialized
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hmac_callback_forward_rejects_invalid_signature(monkeypatch) -> None:
|
||||
token = "test-forwarder-token"
|
||||
|
||||
Reference in New Issue
Block a user