fix(telegram): require durable callback receipts
This commit is contained in:
@@ -59,22 +59,56 @@ def test_callback_ingress_defaults_to_unverified_fail_closed() -> None:
|
||||
assert status["fail_closed_reason"] == "callback_ingress_not_verified"
|
||||
|
||||
|
||||
def test_callback_ingress_receipt_has_24_hour_freshness_contract() -> None:
|
||||
def test_callback_ingress_receipt_has_five_minute_freshness_contract() -> None:
|
||||
gateway = _ConfiguredGateway()
|
||||
assert CALLBACK_INGRESS_RECEIPT_MAX_AGE_SECONDS == 24 * 60 * 60
|
||||
assert CALLBACK_INGRESS_RECEIPT_MAX_AGE_SECONDS == 5 * 60
|
||||
|
||||
gateway._last_callback_ingress_source = "canonical_webhook"
|
||||
gateway._last_callback_ingress_receipt_at = datetime.now(UTC) - timedelta(hours=23)
|
||||
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(hours=25)
|
||||
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
|
||||
@@ -154,23 +188,31 @@ def test_ready_markup_still_removes_unknown_ghost_callbacks() -> None:
|
||||
assert all("scale:" not in str(button) for button in rendered)
|
||||
|
||||
|
||||
def test_machine_registry_excludes_ghosts_and_describes_shadow_tune() -> None:
|
||||
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
|
||||
tune = resolve_callback_action_contract("tune:APR-1:1:nonce")
|
||||
assert tune is not None
|
||||
assert tune.state == "shadow_candidate_only"
|
||||
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_every_yaml_action_has_a_renderable_registry_contract() -> None:
|
||||
def test_only_low_risk_read_only_yaml_actions_are_renderable() -> None:
|
||||
registry = load_action_registry()
|
||||
assert registry
|
||||
|
||||
@@ -181,9 +223,12 @@ def test_every_yaml_action_has_a_renderable_registry_contract() -> None:
|
||||
else f"{action}:INC-20260716-TEST"
|
||||
)
|
||||
contract = resolve_callback_action_contract(callback_data)
|
||||
assert contract is not None, action
|
||||
assert contract.handler == "callback_dispatcher.dispatch_action"
|
||||
assert contract.executor.startswith("mcp:")
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user