diff --git a/app/access/__init__.py b/app/access/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/access/qr.py b/app/access/qr.py new file mode 100644 index 0000000..b06e1ae --- /dev/null +++ b/app/access/qr.py @@ -0,0 +1,13 @@ +import io + +import segno + + +def invite_link(bot_username: str, token: str) -> str: + return f"https://t.me/{bot_username}?start={token}" + + +def qr_png(data: str) -> bytes: + buf = io.BytesIO() + segno.make(data, error="m").save(buf, kind="png", scale=10, border=2) + return buf.getvalue() diff --git a/app/access/service.py b/app/access/service.py new file mode 100644 index 0000000..50a8285 --- /dev/null +++ b/app/access/service.py @@ -0,0 +1,174 @@ +"""Invites, members and revocation (SPEC §3.1, ADR-011).""" + +import hashlib +import secrets +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from enum import StrEnum +from typing import Any, cast + +from sqlalchemy import CursorResult, delete, func, select, update + +from app.config import AccessSettings +from app.db.models import AccessAttempt, Draft, FsmState, Invite, Role, User, utcnow +from app.db.session import SessionMaker + +LAST_SEEN_EVERY = timedelta(minutes=5) +ATTEMPT_WINDOW = timedelta(hours=1) + + +class RedeemStatus(StrEnum): + OK = "ok" + ALREADY_MEMBER = "already_member" + REJECTED = "rejected" # unknown, used or expired — one answer, no hints which tokens existed + THROTTLED = "throttled" + + +@dataclass(frozen=True) +class TgUser: + tg_id: int + full_name: str + username: str | None = None + + +@dataclass(frozen=True) +class CreatedInvite: + token: str + expires_at: datetime + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +class AccessService: + def __init__(self, sessions: SessionMaker, settings: AccessSettings, clock: Callable[[], datetime] = utcnow): + self._sessions = sessions + self._settings = settings + self._clock = clock + + async def bootstrap_admins(self) -> None: + """ADMIN_IDS become active admins in the DB; everybody else gets in only by invite (SPEC A-8).""" + async with self._sessions.begin() as s: + for tg_id in self._settings.admin_ids: + user = await s.get(User, tg_id) + if user is None: + s.add(User(tg_id=tg_id, full_name=f"admin {tg_id}", role=Role.ADMIN, joined_at=self._clock())) + else: + user.role, user.is_active = Role.ADMIN, True + + async def get_member(self, tg_id: int) -> User | None: + async with self._sessions() as s: + user = await s.get(User, tg_id) + return user if user is not None and user.is_active else None + + async def touch(self, member: User, seen: TgUser) -> None: + """Refresh name and last-seen, at most every few minutes — not a write per update.""" + now = self._clock() + last = member.last_seen_at + if ( + last is not None + and now - _aware(last) < LAST_SEEN_EVERY + and (member.full_name, member.username) == (seen.full_name, seen.username) + ): + return + async with self._sessions.begin() as s: + await s.execute( + update(User) + .where(User.tg_id == member.tg_id) + .values(last_seen_at=now, full_name=seen.full_name, username=seen.username) + ) + + async def create_invite(self, admin_id: int) -> CreatedInvite: + token = secrets.token_urlsafe(16) # 128 bits, 22 chars of [A-Za-z0-9_-]: fits the 64-char deep link payload + expires_at = self._clock() + timedelta(hours=self._settings.invite_ttl_hours) + async with self._sessions.begin() as s: + s.add( + Invite( + token_hash=hash_token(token), created_by=admin_id, created_at=self._clock(), expires_at=expires_at + ) + ) + return CreatedInvite(token=token, expires_at=expires_at) + + async def redeem(self, token: str, who: TgUser) -> RedeemStatus: + now = self._clock() + try: + async with self._sessions.begin() as s: + existing = await s.get(User, who.tg_id) + if existing is not None and existing.is_active: + return RedeemStatus.ALREADY_MEMBER + failed = await s.scalar( + select(func.count()) + .select_from(AccessAttempt) + .where( + AccessAttempt.tg_id == who.tg_id, + AccessAttempt.ok.is_(False), + AccessAttempt.at > now - ATTEMPT_WINDOW, + ) + ) + if (failed or 0) >= self._settings.max_failed_attempts: + return RedeemStatus.THROTTLED + + invite = await s.scalar(select(Invite).where(Invite.token_hash == hash_token(token))) + if invite is None or invite.used_by is not None or _aware(invite.expires_at) <= now: + raise _Rejected + + if existing is None: + s.add( + User( + tg_id=who.tg_id, + full_name=who.full_name, + username=who.username, + role=Role.MEMBER, + invited_by=invite.created_by, + joined_at=now, + last_seen_at=now, + ) + ) + else: # revoked earlier, invited again + existing.is_active, existing.role, existing.invited_by = True, Role.MEMBER, invite.created_by + existing.full_name, existing.username, existing.last_seen_at = who.full_name, who.username, now + await s.flush() # the user row must exist before invites.used_by points at it + + # Claim atomically: of two people scanning the same QR only one UPDATE matches the row, + # the loser's transaction (with its freshly created user) is rolled back. + result = await s.execute( + update(Invite) + .where(Invite.id == invite.id, Invite.used_by.is_(None)) + .values(used_by=who.tg_id, used_at=now) + ) + if cast(CursorResult[Any], result).rowcount != 1: + raise _Rejected + s.add(AccessAttempt(tg_id=who.tg_id, at=now, ok=True)) + except _Rejected: + async with self._sessions.begin() as s: + s.add(AccessAttempt(tg_id=who.tg_id, at=now, ok=False)) + return RedeemStatus.REJECTED + return RedeemStatus.OK + + async def list_members(self) -> list[User]: + async with self._sessions() as s: + rows = await s.scalars(select(User).where(User.is_active.is_(True)).order_by(User.role, User.joined_at)) + return list(rows) + + async def revoke(self, tg_id: int) -> bool: + """Cut a member off at once: their updates are ignored from now on, draft and dialog state are dropped.""" + async with self._sessions.begin() as s: + user = await s.get(User, tg_id) + if user is None or not user.is_active or user.role is Role.ADMIN: + return False + user.is_active = False + await s.execute(delete(Draft).where(Draft.user_id == tg_id)) + # FSM keys are "bot:chat:user:…"; in a private chat chat == user + await s.execute(delete(FsmState).where(FsmState.key.like(f"%:{tg_id}:{tg_id}:%"))) + return True + + +class _Rejected(Exception): + pass + + +def _aware(value: datetime) -> datetime: + # SQLite hands back naive datetimes even for timezone=True columns; everything we store is UTC. + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) diff --git a/app/bot/middlewares/access.py b/app/bot/middlewares/access.py index 64ecfbb..363c773 100644 --- a/app/bot/middlewares/access.py +++ b/app/bot/middlewares/access.py @@ -1,6 +1,7 @@ -"""Access gate on every update — messages and button presses alike (SPEC A-5). +"""Access gate on every update — messages and button presses alike (SPEC A-5, ADR-011). -Until invites land (#25) the only members are the bootstrap admins from `ADMIN_IDS`. +Members (active users in the DB) pass with `member` injected into handler data. Outsiders pass only with +`/start ` — the invite deep link; everything else gets one "invite only" reply a day, then silence. """ import time @@ -8,18 +9,28 @@ from collections.abc import Awaitable, Callable from typing import Any from aiogram import BaseMiddleware -from aiogram.types import CallbackQuery, Message, TelegramObject +from aiogram.types import CallbackQuery, Message, TelegramObject, User +from app.access.service import AccessService, TgUser from app.texts import Texts -IsMember = Callable[[int], Awaitable[bool]] - NO_ACCESS_REPLY_EVERY = 24 * 3600.0 +def tg_user(user: User) -> TgUser: + return TgUser(tg_id=user.id, full_name=user.full_name, username=user.username) + + +def is_invite_link(event: TelegramObject) -> bool: + if not isinstance(event, Message) or not event.text: + return False + parts = event.text.split(maxsplit=1) + return len(parts) == 2 and parts[0].split("@")[0] == "/start" + + class AccessMiddleware(BaseMiddleware): - def __init__(self, is_member: IsMember, texts: Texts, clock: Callable[[], float] = time.monotonic) -> None: - self._is_member = is_member + def __init__(self, access: AccessService, texts: Texts, clock: Callable[[], float] = time.monotonic) -> None: + self._access = access self._texts = texts self._clock = clock # tg_id -> when we last told this outsider "invite only"; answering every message would let them spam us. @@ -31,11 +42,18 @@ class AccessMiddleware(BaseMiddleware): event: TelegramObject, data: dict[str, Any], ) -> Any: - user = data.get("event_from_user") - if user is not None and await self._is_member(user.id): + user: User | None = data.get("event_from_user") + if user is None: + return None + member = await self._access.get_member(user.id) + if member is not None: + await self._access.touch(member, tg_user(user)) + data["member"] = member return await handler(event, data) - if user is not None: - await self._refuse(event, user.id) + if is_invite_link(event): + data["member"] = None + return await handler(event, data) + await self._refuse(event, user.id) return None async def _refuse(self, event: TelegramObject, tg_id: int) -> None: diff --git a/app/bot/routers/common.py b/app/bot/routers/common.py index 9ce708c..aa960d5 100644 --- a/app/bot/routers/common.py +++ b/app/bot/routers/common.py @@ -1,15 +1,18 @@ -"""/start, /help, /menu, main menu buttons and in-section navigation.""" +"""/start (incl. invite deep links), /help, /menu, main menu buttons and in-section navigation.""" from aiogram import F, Router -from aiogram.filters import Command, CommandStart +from aiogram.filters import Command, CommandObject, CommandStart from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, Message +from app.access.service import AccessService, RedeemStatus from app.bot.menu import Nav, NavAction, Section, main_menu, section_by_button, section_nav +from app.bot.middlewares.access import tg_user +from app.db.models import Role, User from app.texts import Texts -def build_router(texts: Texts, admin_ids: frozenset[int]) -> Router: +def build_router(texts: Texts, access: AccessService) -> Router: router = Router(name="common") sections = section_by_button(texts) section_text = { @@ -17,33 +20,39 @@ def build_router(texts: Texts, admin_ids: frozenset[int]) -> Router: Section.STATUS: texts.section.status, Section.LOGS: texts.section.logs, Section.ALERTS: texts.section.alerts, - Section.MEMBERS: texts.section.members, } - def is_admin(message: Message) -> bool: - return message.from_user is not None and message.from_user.id in admin_ids - @router.message(CommandStart()) - async def start(message: Message, state: FSMContext) -> None: + async def start(message: Message, command: CommandObject, state: FSMContext, member: User | None) -> None: + if member is None: + # Only an outsider with an invite token gets here (AccessMiddleware); a member's /start is a plain start + if message.from_user is None or not command.args: + return + status = await access.redeem(command.args, tg_user(message.from_user)) + if status is RedeemStatus.THROTTLED: + return # brute force: stay silent (SPEC A-6) + if status is RedeemStatus.REJECTED: + await message.answer(texts.start.invite_rejected) + return + member = await access.get_member(message.from_user.id) + if member is None: + return await state.clear() - await message.answer(texts.start.welcome, reply_markup=main_menu(texts, is_admin=is_admin(message))) + await message.answer(texts.start.welcome, reply_markup=main_menu(texts, is_admin=member.role is Role.ADMIN)) @router.message(Command("menu")) - async def menu(message: Message, state: FSMContext) -> None: + async def menu(message: Message, state: FSMContext, member: User) -> None: await state.clear() - await message.answer(texts.menu.placeholder, reply_markup=main_menu(texts, is_admin=is_admin(message))) + await message.answer(texts.menu.placeholder, reply_markup=main_menu(texts, is_admin=member.role is Role.ADMIN)) @router.message(Command("help")) async def help_(message: Message) -> None: await message.answer(texts.help.text) - @router.message(F.text.in_(sections)) + @router.message(F.text.in_({k for k, v in sections.items() if v is not Section.MEMBERS})) async def open_section(message: Message, state: FSMContext) -> None: - section = sections[message.text or ""] - if section is Section.MEMBERS and not is_admin(message): - return await state.clear() - await message.answer(section_text[section], reply_markup=section_nav(texts)) + await message.answer(section_text[sections[message.text or ""]], reply_markup=section_nav(texts)) @router.callback_query(Nav.filter()) async def navigate(query: CallbackQuery, callback_data: Nav, state: FSMContext) -> None: diff --git a/app/bot/routers/members.py b/app/bot/routers/members.py new file mode 100644 index 0000000..44f158b --- /dev/null +++ b/app/bot/routers/members.py @@ -0,0 +1,111 @@ +"""«👥 Участники» for admins: list, invite by QR, revoke access (SPEC A-1, A-7).""" + +from datetime import datetime +from enum import StrEnum + +from aiogram import Bot, F, Router +from aiogram.filters.callback_data import CallbackData +from aiogram.fsm.context import FSMContext +from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardMarkup, Message +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from app.access.qr import invite_link, qr_png +from app.access.service import AccessService +from app.bot.menu import Nav, NavAction +from app.db.models import Role, User +from app.texts import Texts + + +class MembersAction(StrEnum): + INVITE = "inv" + ASK_REVOKE = "ask" + REVOKE = "rev" + LIST = "list" + + +class MembersCb(CallbackData, prefix="mem"): + action: MembersAction + tg_id: int = 0 + + +def _fmt(dt: datetime | None, never: str) -> str: + return dt.strftime("%d.%m %H:%M") if dt else never + + +def render_members(texts: Texts, members: list[User]) -> tuple[str, InlineKeyboardMarkup]: + t = texts.members + lines = [t.title.format(count=len(members)), ""] + for m in members: + lines.append( + t.line.format( + icon="👑" if m.role is Role.ADMIN else "👤", + name=m.full_name, + username=f" @{m.username}" if m.username else "", + joined=_fmt(m.joined_at, t.never), + seen=_fmt(m.last_seen_at, t.never), + ) + ) + kb = InlineKeyboardBuilder() + kb.button(text=t.invite, callback_data=MembersCb(action=MembersAction.INVITE)) + for m in members: + if m.role is not Role.ADMIN: + kb.button( + text=t.revoke.format(name=m.full_name), + callback_data=MembersCb(action=MembersAction.ASK_REVOKE, tg_id=m.tg_id), + ) + kb.button(text=texts.menu.back, callback_data=Nav(action=NavAction.BACK)) + kb.adjust(1) + return "\n".join(lines), kb.as_markup() + + +def build_router(texts: Texts, access: AccessService) -> Router: + router = Router(name="members") + t = texts.members + # Every handler here is admin-only; a member who somehow has the button (e.g. was demoted) gets nothing. + + def is_admin(member: User | None) -> bool: + return member is not None and member.role is Role.ADMIN + + @router.message(F.text == texts.menu.members) + async def open_members(message: Message, state: FSMContext, member: User) -> None: + if not is_admin(member): + return + await state.clear() + text, kb = render_members(texts, await access.list_members()) + await message.answer(text, reply_markup=kb) + + @router.callback_query(MembersCb.filter()) + async def on_action(query: CallbackQuery, callback_data: MembersCb, bot: Bot, member: User) -> None: + if not is_admin(member) or not isinstance(query.message, Message): + await query.answer() + return + msg = query.message + match callback_data.action: + case MembersAction.INVITE: + invite = await access.create_invite(member.tg_id) + me = await bot.me() + link = invite_link(me.username or "", invite.token) + caption = t.invite_caption.format(link=link, expires=invite.expires_at.strftime("%d.%m %H:%M")) + await msg.answer_photo(BufferedInputFile(qr_png(link), "invite.png"), caption=caption) + case MembersAction.ASK_REVOKE: + target = await access.get_member(callback_data.tg_id) + name = target.full_name if target else str(callback_data.tg_id) + kb = InlineKeyboardBuilder() + kb.button( + text=t.confirm_yes, callback_data=MembersCb(action=MembersAction.REVOKE, tg_id=callback_data.tg_id) + ) + kb.button(text=t.confirm_no, callback_data=MembersCb(action=MembersAction.LIST)) + await msg.edit_text(t.confirm.format(name=name), reply_markup=kb.as_markup()) + case MembersAction.REVOKE: + target = await access.get_member(callback_data.tg_id) + name = target.full_name if target else str(callback_data.tg_id) + ok = await access.revoke(callback_data.tg_id) + text, kb_markup = render_members(texts, await access.list_members()) + note = t.revoked.format(name=name) if ok else t.not_revoked + await msg.edit_text(f"{note}\n\n{text}", reply_markup=kb_markup) + case MembersAction.LIST: + text, kb_markup = render_members(texts, await access.list_members()) + await msg.edit_text(text, reply_markup=kb_markup) + await query.answer() + + return router diff --git a/app/config.py b/app/config.py index aaac296..e0c6ecc 100644 --- a/app/config.py +++ b/app/config.py @@ -20,6 +20,9 @@ class AccessSettings(BaseSettings): # Bootstrap admins (SPEC A-8). Comma-separated in the env: ADMIN_IDS=1,2 admin_ids: Annotated[frozenset[int], NoDecode] = frozenset() + invite_ttl_hours: float = Field(default=24.0, gt=0) + # More failed /start per hour than this — silence (SPEC A-6) + max_failed_attempts: int = Field(default=5, ge=1) @field_validator("admin_ids", mode="before") @classmethod diff --git a/app/main.py b/app/main.py index 097eefb..9b1ed17 100644 --- a/app/main.py +++ b/app/main.py @@ -7,9 +7,10 @@ import logging from aiogram import Bot, Dispatcher from app import __version__ +from app.access.service import AccessService from app.bot.fsm import make_storage from app.bot.middlewares.access import AccessMiddleware -from app.bot.routers.common import build_router +from app.bot.routers import common, members from app.config import Settings from app.db.maintenance import cleanup_loop from app.db.migrate import upgrade @@ -21,18 +22,14 @@ from app.texts import Texts, load_texts log = logging.getLogger("tgit") -def build_dispatcher(settings: Settings, texts: Texts, sessions: SessionMaker) -> Dispatcher: - admin_ids = settings.access.admin_ids - - async def is_member(tg_id: int) -> bool: - return tg_id in admin_ids - +def build_dispatcher(texts: Texts, sessions: SessionMaker, access: AccessService) -> Dispatcher: dp = Dispatcher(storage=make_storage(sessions)) - access = AccessMiddleware(is_member, texts) + gate = AccessMiddleware(access, texts) # Outer middleware: runs before filters, so nothing — not even a stale button — reaches a handler unchecked. - dp.message.outer_middleware(access) - dp.callback_query.outer_middleware(access) - dp.include_router(build_router(texts, admin_ids)) + dp.message.outer_middleware(gate) + dp.callback_query.outer_middleware(gate) + dp.include_router(members.build_router(texts, access)) + dp.include_router(common.build_router(texts, access)) return dp @@ -41,7 +38,9 @@ async def run(settings: Settings) -> None: engine = make_engine(settings.database.url) await upgrade(engine) sessions = make_sessionmaker(engine) - dp = build_dispatcher(settings, texts, sessions) + access = AccessService(sessions, settings.access) + await access.bootstrap_admins() + dp = build_dispatcher(texts, sessions, access) rt = settings.runtime background = [ asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval)), diff --git a/app/texts.py b/app/texts.py index 5308771..2494222 100644 --- a/app/texts.py +++ b/app/texts.py @@ -24,6 +24,7 @@ class MenuTexts(_Strict): class StartTexts(_Strict): welcome: str no_access: str + invite_rejected: str class HelpTexts(_Strict): @@ -39,11 +40,26 @@ class SectionTexts(_Strict): cancelled: str +class MembersTexts(_Strict): + title: str + line: str + never: str + invite: str + revoke: str + confirm: str + confirm_yes: str + confirm_no: str + revoked: str + not_revoked: str + invite_caption: str + + class Texts(_Strict): menu: MenuTexts start: StartTexts help: HelpTexts section: SectionTexts + members: MembersTexts def load_texts(config_dir: Path) -> Texts: diff --git a/config/texts.yaml b/config/texts.yaml index becd9e2..96984b1 100644 --- a/config/texts.yaml +++ b/config/texts.yaml @@ -19,6 +19,7 @@ start: 📜 Логи — хвост, свой grep или вопрос агенту 🚨 Алерты — падения, перегрузка, список no_access: "Доступ к боту — только по приглашению. Попросите QR-код у администратора." + invite_rejected: "Приглашение недействительно: истекло, уже использовано или ссылка неверная. Попросите новое у администратора." help: text: |- @@ -34,3 +35,20 @@ section: alerts: "🚨 Алерты\n\nРаздел в разработке." members: "👥 Участники\n\nРаздел в разработке." cancelled: "Отменено. Главное меню — кнопками внизу." + +members: + title: "👥 Участники: {count}" + line: "{icon} {name}{username} · с {joined} · был(а) {seen}" + never: "—" + invite: "➕ Пригласить" + revoke: "🚫 {name}" + confirm: "Отозвать доступ у {name}? Его черновик удалится, кнопки перестанут работать." + confirm_yes: "Да, отозвать" + confirm_no: "Нет" + revoked: "Доступ отозван: {name}." + not_revoked: "Нельзя отозвать: администратор или уже без доступа." + invite_caption: |- + Приглашение в tgit — отсканируйте QR или откройте ссылку: + {link} + + Одноразовое, действует до {expires} UTC. diff --git a/tests/access/__init__.py b/tests/access/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/access/test_service.py b/tests/access/test_service.py new file mode 100644 index 0000000..c2f38ae --- /dev/null +++ b/tests/access/test_service.py @@ -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 == "Новое имя" diff --git a/tests/bot/test_common.py b/tests/bot/test_common.py index 0be65f0..2ca8df1 100644 --- a/tests/bot/test_common.py +++ b/tests/bot/test_common.py @@ -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)) diff --git a/tests/bot/test_members.py b/tests/bot/test_members.py new file mode 100644 index 0000000..5e5cfd5 --- /dev/null +++ b/tests/bot/test_members.py @@ -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)) diff --git a/tests/conftest.py b/tests/conftest.py index 1d71aa1..856c49d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/fakes.py b/tests/fakes.py index 272e7ef..f03867d 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -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]