feat(bot): доступ по QR-приглашениям, раздел «Участники», отзыв доступа

- AccessService: одноразовые приглашения (128 бит, в БД только sha256, TTL 24 ч),
  атомарный захват приглашения (условный UPDATE — из двух сканов одного QR проходит один),
  антиперебор (> 5 неудач в час — молчание), отзыв (черновик и FSM удаляются), повторное приглашение
- ADMIN_IDS заводятся в БД при старте; дальше — только по приглашению
- AccessMiddleware: участники из БД (+ last_seen раз в 5 мин), новичкам — только /start <токен>
- «👥 Участники» для админов: список, QR (segno) + ссылка, отзыв с подтверждением
- один ответ на неверный/истёкший/использованный токен — без подсказок

Refs #25

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 19:03:50 +03:00
co-authored by Claude Opus 5.5
parent 4e5930b4c0
commit 219bebd5a2
16 changed files with 655 additions and 44 deletions
View File
+138
View File
@@ -0,0 +1,138 @@
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import select
from app.access.service import AccessService, RedeemStatus, TgUser, hash_token
from app.config import AccessSettings
from app.db.models import Draft, FsmState, Invite, Role, User
from app.db.session import SessionMaker
ADMIN = 1
NEWBIE = TgUser(tg_id=500, full_name="Новичок", username="newbie")
class Clock:
def __init__(self) -> None:
self.now = datetime(2026, 10, 15, 12, 0, tzinfo=UTC)
def __call__(self) -> datetime:
return self.now
@pytest.fixture
def clock() -> Clock:
return Clock()
@pytest.fixture
async def svc(sessions: SessionMaker, clock: Clock) -> AccessService:
service = AccessService(sessions, AccessSettings(admin_ids=frozenset({ADMIN}), invite_ttl_hours=24), clock=clock)
await service.bootstrap_admins()
return service
async def test_bootstrap_is_idempotent_and_restores_admin(svc: AccessService, sessions: SessionMaker) -> None:
async with sessions.begin() as s:
admin = await s.get(User, ADMIN)
assert admin is not None
admin.is_active, admin.role = False, Role.MEMBER
await svc.bootstrap_admins()
await svc.bootstrap_admins()
member = await svc.get_member(ADMIN)
assert member is not None and member.role is Role.ADMIN
async def test_invite_stores_only_hash(svc: AccessService, sessions: SessionMaker, clock: Clock) -> None:
invite = await svc.create_invite(ADMIN)
assert len(invite.token) == 22 and invite.token.replace("-", "").replace("_", "").isalnum()
assert invite.expires_at == clock.now + timedelta(hours=24)
async with sessions() as s:
stored = await s.scalar(select(Invite))
assert stored is not None and stored.token_hash == hash_token(invite.token) != invite.token
async def test_redeem_once_then_rejected_for_others(svc: AccessService, sessions: SessionMaker) -> None:
invite = await svc.create_invite(ADMIN)
assert await svc.redeem(invite.token, NEWBIE) is RedeemStatus.OK
assert await svc.redeem(invite.token, NEWBIE) is RedeemStatus.ALREADY_MEMBER
assert await svc.redeem(invite.token, TgUser(tg_id=501, full_name="x")) is RedeemStatus.REJECTED
member = await svc.get_member(NEWBIE.tg_id)
assert member is not None and member.role is Role.MEMBER and member.invited_by == ADMIN
async with sessions() as s:
stored = await s.scalar(select(Invite))
assert stored is not None and stored.used_by == NEWBIE.tg_id
assert await s.get(User, 501) is None # the loser's user row was rolled back
async def test_expired_invite_rejected(svc: AccessService, clock: Clock) -> None:
invite = await svc.create_invite(ADMIN)
clock.now += timedelta(hours=24, seconds=1)
assert await svc.redeem(invite.token, NEWBIE) is RedeemStatus.REJECTED
assert await svc.get_member(NEWBIE.tg_id) is None
async def test_brute_force_is_throttled_even_with_a_valid_token(svc: AccessService, clock: Clock) -> None:
for i in range(5):
assert await svc.redeem(f"guess-{i}", NEWBIE) is RedeemStatus.REJECTED
invite = await svc.create_invite(ADMIN)
assert await svc.redeem(invite.token, NEWBIE) is RedeemStatus.THROTTLED
clock.now += timedelta(hours=1, seconds=1)
assert await svc.redeem(invite.token, NEWBIE) is RedeemStatus.OK
async def test_revoke_drops_access_draft_and_dialog(svc: AccessService, sessions: SessionMaker, clock: Clock) -> None:
await svc.redeem((await svc.create_invite(ADMIN)).token, NEWBIE)
async with sessions.begin() as s:
s.add(Draft(user_id=NEWBIE.tg_id, base_sha="0" * 40, expires_at=clock.now + timedelta(hours=1)))
s.add(FsmState(key=f"42:{NEWBIE.tg_id}:{NEWBIE.tg_id}:::default", state="x", data={}))
s.add(FsmState(key=f"42:{ADMIN}:{ADMIN}:::default", state="y", data={}))
assert await svc.revoke(NEWBIE.tg_id)
assert await svc.get_member(NEWBIE.tg_id) is None
async with sessions() as s:
assert await s.scalar(select(Draft)) is None
assert [r.key for r in await s.scalars(select(FsmState))] == [f"42:{ADMIN}:{ADMIN}:::default"]
async def test_admin_cannot_be_revoked_and_unknown_is_noop(svc: AccessService) -> None:
assert not await svc.revoke(ADMIN)
assert not await svc.revoke(999)
async def test_revoked_member_can_be_invited_again(svc: AccessService) -> None:
await svc.redeem((await svc.create_invite(ADMIN)).token, NEWBIE)
await svc.revoke(NEWBIE.tg_id)
assert await svc.redeem((await svc.create_invite(ADMIN)).token, NEWBIE) is RedeemStatus.OK
assert await svc.get_member(NEWBIE.tg_id) is not None
async def test_members_listed_admins_first(svc: AccessService) -> None:
await svc.redeem((await svc.create_invite(ADMIN)).token, NEWBIE)
assert [m.tg_id for m in await svc.list_members()] == [ADMIN, NEWBIE.tg_id]
async def test_touch_is_throttled_but_tracks_renames(svc: AccessService, clock: Clock) -> None:
member = await svc.get_member(ADMIN)
assert member is not None
await svc.touch(member, TgUser(ADMIN, "Админ", "boss"))
first = await svc.get_member(ADMIN)
assert first is not None and first.full_name == "Админ" and first.last_seen_at is not None
clock.now += timedelta(minutes=1)
await svc.touch(first, TgUser(ADMIN, "Админ", "boss"))
same = await svc.get_member(ADMIN)
assert same is not None and same.last_seen_at == first.last_seen_at # no write within 5 minutes
await svc.touch(same, TgUser(ADMIN, "Новое имя", "boss"))
renamed = await svc.get_member(ADMIN)
assert renamed is not None and renamed.full_name == "Новое имя"
+4 -4
View File
@@ -2,8 +2,8 @@ import pytest
from aiogram.methods import AnswerCallbackQuery, EditMessageText, SendMessage
from aiogram.types import InlineKeyboardMarkup, ReplyKeyboardMarkup
from app.access.service import AccessService
from app.bot.menu import Nav, NavAction
from app.config import Settings
from app.db.session import SessionMaker
from app.main import build_dispatcher
from app.texts import Texts
@@ -12,8 +12,8 @@ from tests.fakes import TgClient
@pytest.fixture
def tg(settings: Settings, texts: Texts, sessions: SessionMaker) -> TgClient:
return TgClient(build_dispatcher(settings, texts, sessions))
def tg(texts: Texts, sessions: SessionMaker, access: AccessService) -> TgClient:
return TgClient(build_dispatcher(texts, sessions, access))
def buttons(markup: ReplyKeyboardMarkup) -> list[str]:
@@ -45,7 +45,7 @@ async def test_outsider_stale_button_is_swallowed(tg: TgClient) -> None:
assert len(tg.session.sent(AnswerCallbackQuery)) == 1
@pytest.mark.parametrize("section", ["commit", "status", "logs", "alerts", "members"])
@pytest.mark.parametrize("section", ["commit", "status", "logs", "alerts"])
async def test_menu_button_opens_section_with_nav(tg: TgClient, texts: Texts, section: str) -> None:
await tg.send(ADMIN, getattr(texts.menu, section))
+94
View File
@@ -0,0 +1,94 @@
import re
import pytest
from aiogram.methods import AnswerCallbackQuery, EditMessageText, SendMessage, SendPhoto
from aiogram.types import ReplyKeyboardMarkup
from app.access.service import AccessService
from app.bot.routers.members import MembersAction, MembersCb
from app.db.session import SessionMaker
from app.main import build_dispatcher
from app.texts import Texts
from tests.conftest import ADMIN
from tests.fakes import TgClient
NEWBIE = 3003
@pytest.fixture
def tg(texts: Texts, sessions: SessionMaker, access: AccessService) -> TgClient:
return TgClient(build_dispatcher(texts, sessions, access))
async def invite_token(tg: TgClient) -> str:
await tg.press(ADMIN, MembersCb(action=MembersAction.INVITE).pack())
[photo] = tg.session.sent(SendPhoto)
match = re.search(r"https://t\.me/tgit_test_bot\?start=([A-Za-z0-9_-]+)", photo.caption or "")
assert match, photo.caption
tg.session.requests.clear()
return match.group(1)
async def test_admin_sees_members_list(tg: TgClient, texts: Texts) -> None:
await tg.send(ADMIN, texts.menu.members)
[reply] = tg.session.sent(SendMessage)
assert reply.text.startswith(texts.members.title.format(count=1))
async def test_full_invite_flow(tg: TgClient, texts: Texts) -> None:
token = await invite_token(tg)
await tg.send(NEWBIE, f"/start {token}")
[welcome] = tg.session.sent(SendMessage)
assert welcome.text == texts.start.welcome
assert isinstance(welcome.reply_markup, ReplyKeyboardMarkup)
labels = [b.text for row in welcome.reply_markup.keyboard for b in row]
assert texts.menu.members not in labels # a member, not an admin
# the same QR again, from someone else
tg.session.requests.clear()
await tg.send(NEWBIE + 1, f"/start {token}")
assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.invite_rejected]
async def test_bad_token_rejected_and_bare_start_refused(tg: TgClient, texts: Texts) -> None:
await tg.send(NEWBIE, "/start nope")
await tg.send(NEWBIE + 1, "/start")
assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.invite_rejected, texts.start.no_access]
async def test_revoke_with_confirmation_cuts_access(tg: TgClient, texts: Texts) -> None:
await tg.send(NEWBIE, f"/start {await invite_token(tg)}")
tg.session.requests.clear()
await tg.press(ADMIN, MembersCb(action=MembersAction.ASK_REVOKE, tg_id=NEWBIE).pack())
await tg.press(ADMIN, MembersCb(action=MembersAction.REVOKE, tg_id=NEWBIE).pack())
confirm, done = tg.session.sent(EditMessageText)
assert confirm.text == texts.members.confirm.format(name=f"u{NEWBIE}")
assert done.text.startswith(texts.members.revoked.format(name=f"u{NEWBIE}"))
tg.session.requests.clear()
await tg.send(NEWBIE, texts.menu.commit)
assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.no_access]
async def test_member_cannot_use_admin_actions(tg: TgClient, texts: Texts) -> None:
await tg.send(NEWBIE, f"/start {await invite_token(tg)}")
tg.session.requests.clear()
await tg.send(NEWBIE, texts.menu.members)
await tg.press(NEWBIE, MembersCb(action=MembersAction.INVITE).pack())
assert tg.session.sent(SendMessage) == [] and tg.session.sent(SendPhoto) == []
assert len(tg.session.sent(AnswerCallbackQuery)) == 1
async def test_cancel_revoke_returns_to_list(tg: TgClient, texts: Texts) -> None:
await tg.press(ADMIN, MembersCb(action=MembersAction.LIST).pack())
[edit] = tg.session.sent(EditMessageText)
assert edit.text.startswith(texts.members.title.format(count=1))
+8
View File
@@ -4,6 +4,7 @@ from pathlib import Path
import pytest
from sqlalchemy.ext.asyncio import AsyncEngine
from app.access.service import AccessService
from app.config import AccessSettings, RuntimeSettings, Settings, TelegramSettings
from app.db.migrate import upgrade
from app.db.session import SessionMaker, make_engine, make_sessionmaker
@@ -40,3 +41,10 @@ async def engine() -> AsyncIterator[AsyncEngine]:
@pytest.fixture
def sessions(engine: AsyncEngine) -> SessionMaker:
return make_sessionmaker(engine)
@pytest.fixture
async def access(sessions: SessionMaker, settings: Settings) -> AccessService:
service = AccessService(sessions, settings.access)
await service.bootstrap_admins()
return service
+11 -1
View File
@@ -7,7 +7,7 @@ from typing import Any
from aiogram import Bot, Dispatcher
from aiogram.client.session.base import BaseSession
from aiogram.methods import EditMessageText, SendMessage, TelegramMethod
from aiogram.methods import EditMessageText, GetMe, SendMessage, SendPhoto, TelegramMethod
from aiogram.methods.base import TelegramType
from aiogram.types import Chat, Message, Update, User
@@ -27,6 +27,16 @@ class FakeSession(BaseSession):
timeout: int | None = None, # noqa: ASYNC109 - signature fixed by aiogram BaseSession
) -> TelegramType:
self.requests.append(method)
if isinstance(method, GetMe):
return User(id=42, is_bot=True, first_name="tgit", username="tgit_test_bot") # type: ignore[return-value]
if isinstance(method, SendPhoto):
chat = method.chat_id if isinstance(method.chat_id, int) else 0
return Message( # type: ignore[return-value]
message_id=next(self._ids),
date=datetime.now(UTC),
chat=Chat(id=chat, type="private"),
caption=method.caption,
)
if isinstance(method, SendMessage | EditMessageText):
chat_id = method.chat_id if isinstance(method.chat_id, int) else 0
return Message( # type: ignore[return-value]