Files
hermes-agent/tests/plugins/platforms/telegram/test_inline_picker_offset.py
beardthelion 8e85a0fd8c fix(plugins/telegram): clamp negative inline-query offsets to page zero
Telegram echoes back the client-controlled inline_query.offset string on
every inline query. build_inline_results passed it through int() and
used it directly as a slice start, so a negative offset indexed the
catalog from the tail: small catalogs served their last few entries with
negative-prefixed result ids, and larger catalogs could return an empty
page with a misleading next_offset that skipped most of the catalog.

Clamp the parsed offset to >= 0. Malformed offsets still fall back to
page zero via the existing except path, and valid pagination is
unchanged.
2026-10-06 22:52:16 -05:00

146 lines
5.4 KiB
Python

"""Negative or malformed ``offset`` values must not corrupt inline-picker pagination.
``inline_query.offset`` is client-controlled text echoed back from our previous ``next_offset``;
``int()`` accepts negatives, which would slice the catalog with a negative index (tail items or an
empty page) and emit a ``next_offset`` that resumes from the wrong position.
"""
import sys
from types import SimpleNamespace
import pytest
from plugins.platforms.telegram import inline_picker
@pytest.fixture
def catalog(monkeypatch):
items = [{"name": f"cmd{i:03d}", "description": f"command {i}"} for i in range(120)]
monkeypatch.setattr(inline_picker, "collect_inline_catalog", lambda: items)
return items
def test_negative_offset_returns_first_page(catalog):
results, next_offset = inline_picker.build_inline_results("cmd", "-5")
first, first_next = inline_picker.build_inline_results("cmd", "0")
assert results == first
assert next_offset == first_next == "50"
@pytest.mark.parametrize("offset", ["-1", "-50", "-1000"])
def test_any_negative_offset_is_first_page(catalog, offset):
results, next_offset = inline_picker.build_inline_results("cmd", offset)
assert len(results) == 50
assert results[0]["title"] == "/cmd000"
assert next_offset == "50"
def test_negative_offset_does_not_serve_catalog_tail(monkeypatch):
"""With a catalog smaller than one page the negative slice used to return the tail."""
items = [{"name": f"cmd{i:02d}", "description": ""} for i in range(40)]
monkeypatch.setattr(inline_picker, "collect_inline_catalog", lambda: items)
results, next_offset = inline_picker.build_inline_results("cmd", "-3")
assert len(results) == 40
assert next_offset == ""
@pytest.mark.parametrize("offset", ["", "abc", "3.5", "--5", None])
def test_malformed_offset_falls_back_to_first_page(catalog, offset):
results, next_offset = inline_picker.build_inline_results("cmd", offset)
assert len(results) == 50
assert next_offset == "50"
def test_valid_offset_paginates(catalog):
results, next_offset = inline_picker.build_inline_results("cmd", "50")
assert results[0]["title"] == "/cmd050"
assert next_offset == "100"
last, last_next = inline_picker.build_inline_results("cmd", "100")
assert len(last) == 20
assert last_next == ""
def test_past_end_offset_returns_empty(catalog):
results, next_offset = inline_picker.build_inline_results("cmd", "99999")
assert results == []
assert next_offset == ""
# --- e2e through the real handler: Update -> authz -> build_inline_results -> answer() ---
class _FakeArticle:
def __init__(self, **kw):
self.__dict__.update(kw)
class _FakeMessageContent:
def __init__(self, message_text):
self.message_text = message_text
class _FakeInlineQuery:
def __init__(self, query, offset, user_id="42"):
self.query = query
self.offset = offset
self.from_user = SimpleNamespace(id=user_id, username="alice")
self.answer_calls = []
async def answer(self, articles, cache_time=None, is_personal=None, next_offset=None):
self.answer_calls.append(
{"articles": articles, "cache_time": cache_time,
"is_personal": is_personal, "next_offset": next_offset})
def _adapter(monkeypatch, authorized=True):
"""A TelegramAdapter with just the pieces _handle_inline_query touches."""
import types as _types
from gateway.config import Platform
from plugins.platforms.telegram.adapter import TelegramAdapter
fake_telegram = _types.ModuleType("telegram")
fake_telegram.InlineQueryResultArticle = _FakeArticle
fake_telegram.InputTextMessageContent = _FakeMessageContent
monkeypatch.setitem(sys.modules, "telegram", fake_telegram)
adapter = TelegramAdapter.__new__(TelegramAdapter)
adapter.platform = Platform.TELEGRAM # `name` is a property over self.platform
monkeypatch.setattr(
adapter, "_is_callback_user_authorized", lambda *a, **kw: authorized)
return adapter
@pytest.mark.asyncio
async def test_e2e_negative_offset_serves_first_page(monkeypatch, catalog):
"""``inline_query.offset`` arrives client-controlled on the Update; '-3' must answer the
first page, not the catalog tail or an empty page with a rewound next_offset."""
adapter = _adapter(monkeypatch)
iq = _FakeInlineQuery("cmd", "-3")
await adapter._handle_inline_query(SimpleNamespace(inline_query=iq), None)
(call,) = iq.answer_calls
assert [a.title for a in call["articles"]][:3] == ["/cmd000", "/cmd001", "/cmd002"]
assert len(call["articles"]) == 50
assert call["next_offset"] == "50"
@pytest.mark.asyncio
async def test_e2e_valid_offset_paginates(monkeypatch, catalog):
adapter = _adapter(monkeypatch)
iq = _FakeInlineQuery("cmd", "50")
await adapter._handle_inline_query(SimpleNamespace(inline_query=iq), None)
(call,) = iq.answer_calls
assert call["articles"][0].title == "/cmd050"
assert call["next_offset"] == "100"
@pytest.mark.asyncio
async def test_e2e_unauthorized_gets_empty_answer(monkeypatch, catalog):
"""The catalog is never leaked: unauthorized senders get an empty page with no
pagination regardless of offset."""
adapter = _adapter(monkeypatch, authorized=False)
iq = _FakeInlineQuery("cmd", "-3")
await adapter._handle_inline_query(SimpleNamespace(inline_query=iq), None)
(call,) = iq.answer_calls
assert call["articles"] == []
assert call["next_offset"] is None