From e8c3a321a33d0becbd91c2189dfb198f41d12c00 Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Thu, 22 Oct 2026 18:11:53 +0300 Subject: [PATCH] =?UTF-8?q?feat(access):=20=D0=B4=D0=B5=D0=BC=D0=BE-QR=20?= =?UTF-8?q?=E2=80=94=20=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=B5=20=D0=BF=D1=80=D0=B8=D0=B3=D0=BB=D0=B0?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BB=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - invites.max_uses/uses: обычное приглашение — 1 вход, демо-QR — до DEMO_INVITE_MAX_USES (50), живёт DEMO_INVITE_TTL_HOURS (3 ч); захват атомарный, UPDATE … WHERE uses < max_uses - users.guest: вошедшие по демо-QR; «🧹 Отключить гостей демо» отзывает всех и гасит демо-QR - «👥 Участники»: кнопка «🎤 Демо-QR», гости в списке — 🎟 - миграция 0003 (ADD COLUMN без batch — SQLite не пересоздаёт users); проверена на Postgres - SPEC A-3a, ADR-017, DEMO.md, .env.example Refs #55 Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 + app/access/service.py | 44 +++++++++--- app/bot/routers/members.py | 28 ++++++-- app/config.py | 3 + .../versions/20261022_0003_demo_invite.py | 33 +++++++++ app/db/models.py | 6 ++ app/texts.py | 5 ++ config/texts.yaml | 9 +++ docs/DECISIONS.md | 14 ++++ docs/DEMO.md | 3 + docs/SPEC.md | 1 + tests/access/test_service.py | 69 +++++++++++++++++++ tests/bot/test_members.py | 24 ++++++- tests/db/test_migrations.py | 21 ++++++ 14 files changed, 247 insertions(+), 15 deletions(-) create mode 100644 app/db/migrations/versions/20261022_0003_demo_invite.py diff --git a/.env.example b/.env.example index e7da0d4..2205205 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,8 @@ TELEGRAM_BOT_TOKEN= # (secret) токен от @BotFather ADMIN_IDS= # Telegram ID первых администраторов через запятую LOG_LEVEL=INFO +DEMO_INVITE_MAX_USES=50 # демо-QR для показа (#55): сколько человек войдут по одному QR +DEMO_INVITE_TTL_HOURS=3 # и сколько часов он живёт TELEGRAM_PROXY_URL= # (secret) http://user:pass@host:port или socks5://… — на RF-сервере Telegram недоступен напрямую # --- База данных --- diff --git a/app/access/service.py b/app/access/service.py index 50a8285..8cf56c1 100644 --- a/app/access/service.py +++ b/app/access/service.py @@ -36,6 +36,7 @@ class TgUser: class CreatedInvite: token: str expires_at: datetime + max_uses: int = 1 def hash_token(token: str) -> str: @@ -80,16 +81,25 @@ class AccessService: .values(last_seen_at=now, full_name=seen.full_name, username=seen.username) ) - async def create_invite(self, admin_id: int) -> CreatedInvite: + async def create_invite(self, admin_id: int, demo: bool = False) -> CreatedInvite: + """A personal one-time invite, or the demo QR (#55): up to `demo_invite_max_uses` entrants, short-lived.""" 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) + if demo: + max_uses, ttl = self._settings.demo_invite_max_uses, self._settings.demo_invite_ttl_hours + else: + max_uses, ttl = 1, self._settings.invite_ttl_hours + expires_at = self._clock() + timedelta(hours=ttl) 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 + token_hash=hash_token(token), + created_by=admin_id, + created_at=self._clock(), + expires_at=expires_at, + max_uses=max_uses, ) ) - return CreatedInvite(token=token, expires_at=expires_at) + return CreatedInvite(token=token, expires_at=expires_at, max_uses=max_uses) async def redeem(self, token: str, who: TgUser) -> RedeemStatus: now = self._clock() @@ -111,8 +121,9 @@ class AccessService: 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: + if invite is None or invite.uses >= invite.max_uses or _aware(invite.expires_at) <= now: raise _Rejected + guest = invite.max_uses > 1 if existing is None: s.add( @@ -122,21 +133,23 @@ class AccessService: username=who.username, role=Role.MEMBER, invited_by=invite.created_by, + guest=guest, 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.guest = guest 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, + # Claim atomically: when two people race for the last use 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) + .where(Invite.id == invite.id, Invite.uses < Invite.max_uses) + .values(uses=Invite.uses + 1, used_by=who.tg_id, used_at=now) ) if cast(CursorResult[Any], result).rowcount != 1: raise _Rejected @@ -164,6 +177,21 @@ class AccessService: await s.execute(delete(FsmState).where(FsmState.key.like(f"%:{tg_id}:{tg_id}:%"))) return True + async def revoke_guests(self) -> int: + """After the talk: cut off everybody who came by the demo QR and expire it — a photo of it lets nobody in.""" + now = self._clock() + async with self._sessions.begin() as s: + await s.execute(update(Invite).where(Invite.max_uses > 1, Invite.expires_at > now).values(expires_at=now)) + ids = list( + await s.scalars( + select(User.tg_id).where(User.guest.is_(True), User.is_active.is_(True), User.role != Role.ADMIN) + ) + ) + revoked = 0 + for tg_id in ids: + revoked += await self.revoke(tg_id) + return revoked + class _Rejected(Exception): pass diff --git a/app/bot/routers/members.py b/app/bot/routers/members.py index 44f158b..58b7be6 100644 --- a/app/bot/routers/members.py +++ b/app/bot/routers/members.py @@ -1,4 +1,4 @@ -"""«👥 Участники» for admins: list, invite by QR, revoke access (SPEC A-1, A-7).""" +"""«👥 Участники» for admins: list, invite by QR (personal or the demo one, #55), revoke access (SPEC A-1, A-7).""" from datetime import datetime from enum import StrEnum @@ -21,6 +21,9 @@ class MembersAction(StrEnum): ASK_REVOKE = "ask" REVOKE = "rev" LIST = "list" + DEMO_INVITE = "demo" + ASK_REVOKE_GUESTS = "askg" + REVOKE_GUESTS = "revg" class MembersCb(CallbackData, prefix="mem"): @@ -38,7 +41,7 @@ def render_members(texts: Texts, members: list[User]) -> tuple[str, InlineKeyboa for m in members: lines.append( t.line.format( - icon="👑" if m.role is Role.ADMIN else "👤", + icon="👑" if m.role is Role.ADMIN else "🎟" if m.guest else "👤", name=m.full_name, username=f" @{m.username}" if m.username else "", joined=_fmt(m.joined_at, t.never), @@ -47,6 +50,9 @@ def render_members(texts: Texts, members: list[User]) -> tuple[str, InlineKeyboa ) kb = InlineKeyboardBuilder() kb.button(text=t.invite, callback_data=MembersCb(action=MembersAction.INVITE)) + kb.button(text=t.demo_invite, callback_data=MembersCb(action=MembersAction.DEMO_INVITE)) + if any(m.guest and m.role is not Role.ADMIN for m in members): + kb.button(text=t.revoke_guests, callback_data=MembersCb(action=MembersAction.ASK_REVOKE_GUESTS)) for m in members: if m.role is not Role.ADMIN: kb.button( @@ -81,11 +87,14 @@ def build_router(texts: Texts, access: AccessService) -> Router: return msg = query.message match callback_data.action: - case MembersAction.INVITE: - invite = await access.create_invite(member.tg_id) + case MembersAction.INVITE | MembersAction.DEMO_INVITE: + demo = callback_data.action is MembersAction.DEMO_INVITE + invite = await access.create_invite(member.tg_id, demo=demo) 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")) + caption = (t.demo_invite_caption if demo else t.invite_caption).format( + link=link, expires=invite.expires_at.strftime("%d.%m %H:%M"), max_uses=invite.max_uses + ) 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) @@ -103,6 +112,15 @@ def build_router(texts: Texts, access: AccessService) -> Router: 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.ASK_REVOKE_GUESTS: + kb = InlineKeyboardBuilder() + kb.button(text=t.confirm_yes, callback_data=MembersCb(action=MembersAction.REVOKE_GUESTS)) + kb.button(text=t.confirm_no, callback_data=MembersCb(action=MembersAction.LIST)) + await msg.edit_text(t.confirm_guests, reply_markup=kb.as_markup()) + case MembersAction.REVOKE_GUESTS: + count = await access.revoke_guests() + text, kb_markup = render_members(texts, await access.list_members()) + await msg.edit_text(f"{t.guests_revoked.format(count=count)}\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) diff --git a/app/config.py b/app/config.py index f0d675e..ee0e21b 100644 --- a/app/config.py +++ b/app/config.py @@ -23,6 +23,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) + # Demo QR for the audience at the talk (#55): many entrants, short life + demo_invite_max_uses: int = Field(default=50, ge=2) + demo_invite_ttl_hours: float = Field(default=3.0, gt=0) # More failed /start per hour than this — silence (SPEC A-6) max_failed_attempts: int = Field(default=5, ge=1) diff --git a/app/db/migrations/versions/20261022_0003_demo_invite.py b/app/db/migrations/versions/20261022_0003_demo_invite.py new file mode 100644 index 0000000..8f52826 --- /dev/null +++ b/app/db/migrations/versions/20261022_0003_demo_invite.py @@ -0,0 +1,33 @@ +"""multi-use demo invite (#55): invites.max_uses/uses, users.guest + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-10-22 12:00:00 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0003" +down_revision: str | None = "0002" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Plain ADD COLUMN, not batch: on SQLite a batch rebuilds `users`, which invites reference, and the FKs fail + op.add_column("invites", sa.Column("max_uses", sa.Integer(), server_default="1", nullable=False)) + op.add_column("invites", sa.Column("uses", sa.Integer(), server_default="0", nullable=False)) + op.add_column("users", sa.Column("guest", sa.Boolean(), server_default=sa.false(), nullable=False)) + # Invites redeemed before this migration are spent + op.execute("UPDATE invites SET uses = 1 WHERE used_by IS NOT NULL") + + +def downgrade() -> None: + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.drop_column("guest") + with op.batch_alter_table("invites", schema=None) as batch_op: + batch_op.drop_column("uses") + batch_op.drop_column("max_uses") diff --git a/app/db/models.py b/app/db/models.py index ab3a09b..4873af3 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -22,6 +22,7 @@ from sqlalchemy import ( String, Text, UniqueConstraint, + false, ) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -128,6 +129,8 @@ class User(Base): full_name: Mapped[str] = mapped_column(String(256)) role: Mapped[Role] = mapped_column(enum(Role, "role"), default=Role.MEMBER) is_active: Mapped[bool] = mapped_column(Boolean, default=True) + # Came in by a multi-use demo invite: cut off all at once after the talk (#55) + guest: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false()) invited_by: Mapped[int | None] = mapped_column(TgId, ForeignKey("users.tg_id")) joined_at: Mapped[datetime] = mapped_column(default=utcnow) last_seen_at: Mapped[datetime | None] @@ -142,6 +145,9 @@ class Invite(Base): created_by: Mapped[int] = mapped_column(TgId, ForeignKey("users.tg_id")) created_at: Mapped[datetime] = mapped_column(default=utcnow) expires_at: Mapped[datetime] + # 1 — a personal invite; more — the demo QR shown to an audience (#55). used_by/used_at — the latest entrant. + max_uses: Mapped[int] = mapped_column(Integer, default=1, server_default="1") + uses: Mapped[int] = mapped_column(Integer, default=0, server_default="0") used_by: Mapped[int | None] = mapped_column(TgId, ForeignKey("users.tg_id")) used_at: Mapped[datetime | None] diff --git a/app/texts.py b/app/texts.py index 0c91dcb..be9e762 100644 --- a/app/texts.py +++ b/app/texts.py @@ -49,6 +49,11 @@ class MembersTexts(_Strict): revoked: str not_revoked: str invite_caption: str + demo_invite: str + demo_invite_caption: str + revoke_guests: str + confirm_guests: str + guests_revoked: str class CommitTexts(_Strict): diff --git a/config/texts.yaml b/config/texts.yaml index 4c0a668..0d22e00 100644 --- a/config/texts.yaml +++ b/config/texts.yaml @@ -47,6 +47,15 @@ members: {link} Одноразовое, действует до {expires} UTC. + demo_invite: "🎤 Демо-QR" + demo_invite_caption: |- + 🎤 Демо-доступ к tgit — отсканируйте QR или откройте ссылку: + {link} + + До {max_uses} человек, действует до {expires} UTC. + revoke_guests: "🧹 Отключить гостей демо" + confirm_guests: "Отключить всех, кто вошёл по демо-QR, и погасить сам QR? Их черновики удалятся." + guests_revoked: "Гости демо отключены: {count}. Демо-QR больше не работает." commit: title: "📝 Коммит · /{path}" diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a8c4bb4..aef473e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -155,3 +155,17 @@ прежнее (деплой сразу). Деплой ждёт CI: на общих раннерах это от ~30 с до минут при очереди, таймаут — 30 мин. Возврат к SourceCraft — вторая реализация `CIProvider` поверх готового `SourceCraftClient` (ADR-014), код наблюдателя не меняется. + +## ADR-017: Демо-QR — многоразовое приглашение с лимитом для показа +**Дата:** 2026-10-22 · **Статус:** принято (дополняет ADR-011) + +**Контекст:** на защите зал должен попробовать бота по одному QR с экрана; одноразовое приглашение (ADR-011) пускает +только первого (#55). +**Решение:** у приглашения — лимит входов `max_uses` (обычное — 1) и счётчик `uses`; захват атомарный +(`UPDATE … WHERE uses < max_uses`). Админ выпускает демо-QR отдельной кнопкой: `DEMO_INVITE_MAX_USES` входов (50), +`DEMO_INVITE_TTL_HOURS` (3 ч). Вошедшие помечаются `users.guest`; «Отключить гостей демо» отзывает их всех и гасит +действующие демо-QR, чтобы фото QR больше никого не пускало. +**Альтернативы:** выпустить 50 одноразовых QR — неудобно показывать; роль «гость» без коммита — больше работы, а +код гостей и так исполняется только в песочнице `tgit-runner` (ADR-005/006). +**Последствия:** риск ADR-011 «одна утечка открывает доступ всем» ограничен лимитом, коротким сроком и массовым +отзывом. Гости получают алерты и дневной лимит агента наравне с участниками. diff --git a/docs/DEMO.md b/docs/DEMO.md index 3667ec3..e8497a7 100644 --- a/docs/DEMO.md +++ b/docs/DEMO.md @@ -40,6 +40,9 @@ | 15 | 11:30 | любой | «🚨 Алерты» → «📋 Список» | активные сверху, история, карточка алерта | сц. 10, AL-6 | | 16 | 12:00 | А | «👥 Участники» → Б → «Отозвать» | Б нажимает любую кнопку — тишина | A-7 | +**Зал пробует сам (по желанию, в конце):** А → «👥 Участники» → «🎤 Демо-QR» → QR на экран; до 50 человек входят +гостями 🎟 и пробуют разделы. После показа — «🧹 Отключить гостей демо»: все гости отключены, QR погашен. + Что сказать по ходу (по одной фразе на шаг): - **2–4:** токен 128 бит, одноразовый, 24 ч, в БД только хэш; проверка доступа — middleware на каждом апдейте. - **7:** CI — Gitea Actions репозитория загрузок; бот находит прогон по sha и разворачивает только после зелёного CI. diff --git a/docs/SPEC.md b/docs/SPEC.md index d1d1f03..3bf2c0d 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -57,6 +57,7 @@ | A-3 | Приглашение **одноразовое**, живёт 24 ч (настраивается); в БД хранится только хэш токена | | A-4 | Переход по QR → `/start ` → проверка → пользователь становится участником, видит главное меню. Повторное использование, истёкший или неизвестный токен → отказ | | A-5 | Без приглашения ни одна функция недоступна: проверка — в middleware на **каждом** апдейте (сообщения и нажатия кнопок) | +| A-3a | Исключение для показа (#55): **демо-QR** — одно приглашение на ≤ 50 входов, живёт 3 ч (настраивается). Вошедшие по нему — гости 🎟 (права участника); «Отключить гостей демо» отзывает всех разом и гасит демо-QR | | A-6 | Защита от перебора: не больше 5 неудачных `/start ` в час с одного Telegram ID, дальше — игнор | | A-7 | Администратор видит участников (имя, кто пригласил, когда, последняя активность) и может **отозвать** доступ — с этого момента апдейты пользователя игнорируются, его черновик удаляется | | A-8 | Первые администраторы задаются в `.env` (`ADMIN_IDS`); остальные — только через приглашение | diff --git a/tests/access/test_service.py b/tests/access/test_service.py index c2f38ae..12a5eb1 100644 --- a/tests/access/test_service.py +++ b/tests/access/test_service.py @@ -136,3 +136,72 @@ async def test_touch_is_throttled_but_tracks_renames(svc: AccessService, clock: await svc.touch(same, TgUser(ADMIN, "Новое имя", "boss")) renamed = await svc.get_member(ADMIN) assert renamed is not None and renamed.full_name == "Новое имя" + + +@pytest.fixture +async def demo_svc(sessions: SessionMaker, clock: Clock) -> AccessService: + settings = AccessSettings(admin_ids=frozenset({ADMIN}), demo_invite_max_uses=3, demo_invite_ttl_hours=2) + service = AccessService(sessions, settings, clock=clock) + await service.bootstrap_admins() + return service + + +def guest(n: int) -> TgUser: + return TgUser(tg_id=700 + n, full_name=f"Гость {n}") + + +async def test_demo_invite_lets_in_up_to_the_limit( + demo_svc: AccessService, sessions: SessionMaker, clock: Clock +) -> None: + invite = await demo_svc.create_invite(ADMIN, demo=True) + assert invite.max_uses == 3 and invite.expires_at == clock.now + timedelta(hours=2) + + assert [await demo_svc.redeem(invite.token, guest(n)) for n in range(4)] == [RedeemStatus.OK] * 3 + [ + RedeemStatus.REJECTED + ] + for n in range(3): + member = await demo_svc.get_member(guest(n).tg_id) + assert member is not None and member.guest and member.role is Role.MEMBER + async with sessions() as s: + stored = await s.scalar(select(Invite)) + assert stored is not None and stored.uses == 3 + assert await s.get(User, guest(3).tg_id) is None + + +async def test_demo_invite_expires(demo_svc: AccessService, clock: Clock) -> None: + invite = await demo_svc.create_invite(ADMIN, demo=True) + clock.now += timedelta(hours=2, seconds=1) + + assert await demo_svc.redeem(invite.token, guest(0)) is RedeemStatus.REJECTED + + +async def test_personal_invite_is_not_a_guest_one(demo_svc: AccessService) -> None: + invite = await demo_svc.create_invite(ADMIN) + assert invite.max_uses == 1 + await demo_svc.redeem(invite.token, NEWBIE) + + member = await demo_svc.get_member(NEWBIE.tg_id) + assert member is not None and not member.guest + + +async def test_revoke_guests_cuts_off_only_guests_and_kills_the_qr(demo_svc: AccessService) -> None: + await demo_svc.redeem((await demo_svc.create_invite(ADMIN)).token, NEWBIE) + demo = await demo_svc.create_invite(ADMIN, demo=True) + for n in range(2): + await demo_svc.redeem(demo.token, guest(n)) + + assert await demo_svc.revoke_guests() == 2 + + assert [m.tg_id for m in await demo_svc.list_members()] == [ADMIN, NEWBIE.tg_id] + assert await demo_svc.redeem(demo.token, guest(2)) is RedeemStatus.REJECTED + assert await demo_svc.revoke_guests() == 0 + + +async def test_guest_invited_personally_later_is_no_longer_a_guest(demo_svc: AccessService) -> None: + await demo_svc.redeem((await demo_svc.create_invite(ADMIN, demo=True)).token, guest(0)) + await demo_svc.revoke_guests() + + await demo_svc.redeem((await demo_svc.create_invite(ADMIN)).token, guest(0)) + + member = await demo_svc.get_member(guest(0).tg_id) + assert member is not None and not member.guest diff --git a/tests/bot/test_members.py b/tests/bot/test_members.py index 7daef29..4ab4f57 100644 --- a/tests/bot/test_members.py +++ b/tests/bot/test_members.py @@ -21,8 +21,8 @@ def tg(texts: Texts, sessions: SessionMaker, access: AccessService, repo: Upload return TgClient(build_dispatcher(texts, sessions, access, repo)) -async def invite_token(tg: TgClient) -> str: - await tg.press(ADMIN, MembersCb(action=MembersAction.INVITE).pack()) +async def invite_token(tg: TgClient, action: MembersAction = MembersAction.INVITE) -> str: + await tg.press(ADMIN, MembersCb(action=action).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 @@ -93,3 +93,23 @@ async def test_cancel_revoke_returns_to_list(tg: TgClient, texts: Texts) -> None [edit] = tg.session.sent(EditMessageText) assert edit.text.startswith(texts.members.title.format(count=1)) + + +async def test_demo_qr_lets_many_in_and_guests_are_revoked_together(tg: TgClient, texts: Texts) -> None: + token = await invite_token(tg, MembersAction.DEMO_INVITE) + + for n in range(3): + await tg.send(NEWBIE + n, f"/start {token}") + assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.welcome] * 3 + + tg.session.requests.clear() + await tg.press(ADMIN, MembersCb(action=MembersAction.ASK_REVOKE_GUESTS).pack()) + await tg.press(ADMIN, MembersCb(action=MembersAction.REVOKE_GUESTS).pack()) + confirm, done = tg.session.sent(EditMessageText) + assert confirm.text == texts.members.confirm_guests + assert done.text.startswith(texts.members.guests_revoked.format(count=3)) + + tg.session.requests.clear() + await tg.send(NEWBIE, texts.menu.commit) + await tg.send(NEWBIE + 10, f"/start {token}") + assert [r.text for r in tg.session.sent(SendMessage)] == [texts.start.no_access, texts.start.invite_rejected] diff --git a/tests/db/test_migrations.py b/tests/db/test_migrations.py index f81b126..328db9d 100644 --- a/tests/db/test_migrations.py +++ b/tests/db/test_migrations.py @@ -80,3 +80,24 @@ async def test_deleting_draft_cascades_to_changes(sessions: SessionMaker) -> Non await s.execute(text("DELETE FROM drafts")) async with sessions() as s: assert (await s.execute(text("SELECT count(*) FROM draft_changes"))).scalar_one() == 0 + + +async def test_0003_marks_redeemed_invites_spent() -> None: + eng = make_engine("sqlite+aiosqlite://") + try: + await upgrade(eng, "0002") + async with eng.begin() as conn: + await conn.execute(INSERT_USER, {"id": 1, "role": "admin"}) + await conn.execute( + text( + "INSERT INTO invites (token_hash, created_by, created_at, expires_at, used_by) VALUES " + "('a', 1, '2026-01-01', '2026-01-02', 1), ('b', 1, '2026-01-01', '2026-01-02', NULL)" + ) + ) + await upgrade(eng) + async with eng.connect() as conn: + rows = (await conn.execute(text("SELECT token_hash, max_uses, uses FROM invites ORDER BY 1"))).all() + assert [tuple(r) for r in rows] == [("a", 1, 1), ("b", 1, 0)] + assert (await conn.execute(text("SELECT guest FROM users"))).scalar_one() in (0, False) + finally: + await eng.dispose()