Files
memes_bot/test_access.py
goyban 82b5da41b8 Add public mode with channel membership requirements
Completes TODO #2. PUBLIC_MODE swaps the ALLOWED_USERS allowlist for a
membership check against REQUIRED_CHATS, with REQUIRE_MODE choosing
between needing every chat or just one.

- Stop is_admin() falling back to "everyone is an admin" when
  ADMIN_USERS is unset once PUBLIC_MODE is on. That fallback is the
  original single-owner convenience, but under public mode it handed
  moderation, /edit and /delete to anyone who found the bot. Public
  mode now grants admin to nobody until ADMIN_USERS is set, and says
  so at startup. Private deployments are unaffected.
- Cache membership answers: inline search queries on every keystroke,
  so an uncached check meant several API calls per typed character.
  Grants last five minutes, refusals thirty seconds, so a user who has
  just joined is not locked out for the full window.
- Fail closed when the membership check errors, and validate
  REQUIRED_CHATS at startup so a chat the bot cannot read is reported
  rather than silently refusing every user.
- Refused users get a join prompt with real links instead of silence;
  inline shows a tappable bar that opens the bot and explains why.
  A private bot stays silent as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 21:50:01 +00:00

166 lines
6.2 KiB
Python

"""Access-control tests. Run with: python test_access.py
Covers who gets into the bot in public mode, and the membership cache that
keeps inline search from hammering the Telegram API on every keystroke.
"""
import asyncio
import importlib
import logging
import os
import sys
import time
os.environ.setdefault("BOT_TOKEN", "123456789:AAHtestTokenPlaceholder_ABCDEFGHIJKLMNOP")
os.environ.setdefault("DB_PATH", ":memory:")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from aiogram.exceptions import TelegramAPIError
# Several cases deliberately make the API fail; the bot logs those with a
# traceback, which is correct in production and only noise here.
logging.disable(logging.CRITICAL)
CONFIG_KEYS = ("PUBLIC_MODE", "REQUIRED_CHATS", "REQUIRE_MODE",
"ALLOWED_USERS", "ADMIN_USERS", "ACCEPT_SUBMISSIONS")
class FakeMember:
def __init__(self, status: str, is_member: bool | None = None):
self.status = status
if is_member is not None:
self.is_member = is_member
def load_bot(**env):
"""Reload the bot module with a fresh environment; config is read at import."""
for key in CONFIG_KEYS:
os.environ.pop(key, None)
os.environ.update(env)
import bot
return importlib.reload(bot)
def stub_membership(B, members: dict, calls: list | None = None):
async def get_chat_member(chat, user_id):
if calls is not None:
calls.append((chat, user_id))
if (chat, user_id) not in members:
raise TelegramAPIError(method=None, message="user not found")
return members[(chat, user_id)]
B.bot.get_chat_member = get_chat_member
async def test_member_statuses():
B = load_bot(PUBLIC_MODE="true", REQUIRED_CHATS="@chan")
allowed = ["creator", "administrator", "member"]
denied = ["left", "kicked"]
for status in allowed:
B._membership_cache.clear()
stub_membership(B, {("@chan", 1): FakeMember(status)})
assert await B.has_access(1) is True, f"{status} should be allowed"
for status in denied:
B._membership_cache.clear()
stub_membership(B, {("@chan", 1): FakeMember(status)})
assert await B.has_access(1) is False, f"{status} should be denied"
# A restricted user is still a member unless they have actually left.
B._membership_cache.clear()
stub_membership(B, {("@chan", 1): FakeMember("restricted", is_member=True)})
assert await B.has_access(1) is True, "restricted but still in the chat"
B._membership_cache.clear()
stub_membership(B, {("@chan", 1): FakeMember("restricted", is_member=False)})
assert await B.has_access(1) is False, "restricted and gone"
print(" member statuses are classified correctly, including restricted")
async def test_require_all_and_any():
members = {("@a", 1): FakeMember("member")} # in @a, not in @b
B = load_bot(PUBLIC_MODE="true", REQUIRED_CHATS="@a,@b", REQUIRE_MODE="all")
stub_membership(B, members)
assert await B.has_access(1) is False, "all mode needs every chat"
B = load_bot(PUBLIC_MODE="true", REQUIRED_CHATS="@a,@b", REQUIRE_MODE="any")
stub_membership(B, members)
assert await B.has_access(1) is True, "any mode needs just one"
print(" REQUIRE_MODE all and any behave differently as configured")
async def test_cache_absorbs_repeat_checks():
B = load_bot(PUBLIC_MODE="true", REQUIRED_CHATS="@chan")
calls: list = []
stub_membership(B, {("@chan", 1): FakeMember("member")}, calls)
for _ in range(10):
assert await B.has_access(1) is True
assert len(calls) == 1, f"10 inline keystrokes caused {len(calls)} API calls"
print(" repeated checks hit the API once, not once per keystroke")
async def test_denials_expire_sooner_than_grants():
B = load_bot(PUBLIC_MODE="true", REQUIRED_CHATS="@chan")
assert B.MEMBERSHIP_TTL_DENIED < B.MEMBERSHIP_TTL_ALLOWED
stub_membership(B, {("@chan", 1): FakeMember("member")})
await B.has_access(1)
await B.has_access(2) # unknown user -> denied
now = time.monotonic()
granted = B._membership_cache[(1, "@chan")][1] - now
denied = B._membership_cache[(2, "@chan")][1] - now
assert denied < granted, "a user who just joined should not wait the full window"
print(" denials expire quickly so joining takes effect promptly")
async def test_admin_bypasses_membership():
B = load_bot(PUBLIC_MODE="true", REQUIRED_CHATS="@chan", ADMIN_USERS="111")
calls: list = []
stub_membership(B, {}, calls) # nobody is a member
assert await B.has_access(111) is True, "admins must not be locked out"
assert calls == [], "admin check should not call the API at all"
assert await B.has_access(222) is False
print(" admins bypass the membership check without an API call")
async def test_private_mode_still_uses_the_allowlist():
B = load_bot(ALLOWED_USERS="111,222")
calls: list = []
stub_membership(B, {}, calls)
assert await B.has_access(111) is True
assert await B.has_access(333) is False
assert calls == [], "a private bot should never call getChatMember"
print(" with PUBLIC_MODE off, the allowlist behaves exactly as before")
async def test_api_failure_fails_closed():
B = load_bot(PUBLIC_MODE="true", REQUIRED_CHATS="@chan")
async def always_fails(chat, user_id):
raise TelegramAPIError(method=None, message="bot is not an admin there")
B.bot.get_chat_member = always_fails
assert await B.has_access(1) is False, "an unreadable chat must not open the gate"
print(" a failing membership check denies access rather than opening up")
async def test_open_public_bot():
B = load_bot(PUBLIC_MODE="true") # no REQUIRED_CHATS
assert await B.has_access(999) is True
print(" public mode with no required chats is open to everyone")
async def main():
for test in (test_member_statuses, test_require_all_and_any,
test_cache_absorbs_repeat_checks, test_denials_expire_sooner_than_grants,
test_admin_bypasses_membership, test_private_mode_still_uses_the_allowlist,
test_api_failure_fails_closed, test_open_public_bot):
await test()
print("\nAll access tests passed.")
if __name__ == "__main__":
asyncio.run(main())