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:
@@ -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 == "Новое имя"
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user