Separate staff roles and protect message delivery with operator guides
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-09-13 21:00:53 +09:00
parent d5a9c13b68
commit 3c1a6a02a5
25 changed files with 916 additions and 548 deletions

View File

@@ -0,0 +1,126 @@
import asyncio
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
from aiogram import Bot
from aiogram.exceptions import TelegramForbiddenError, TelegramRetryAfter
from aiogram.methods import CopyMessage
from aiogram.types import Chat, Message, User as TelegramUser
from src.core.broadcast_services import BroadcastService
from src.core.database import async_session_maker
from src.core.services import UserService
from src.handlers import chat_handlers
from src.utils import delivery, telegram_messages
from src.utils.broadcast_jobs import jobs, shutdown_broadcasts, start_broadcast
from test_dispatcher import TelegramStub, dispatch, event
async def test_a_stalled_chat_recipient_does_not_block_other_recipients_or_next_message(monkeypatch):
monkeypatch.setattr(telegram_messages, "COPY_TIMEOUT", 0.1)
async with async_session_maker() as session:
sender = await UserService.get_or_create_user(session, 831)
await UserService.get_or_create_user(session, 832)
await UserService.get_or_create_user(session, 833)
class StallingTelegram(TelegramStub):
async def make_request(self, bot, method, timeout=None):
if getattr(method, "chat_id", None) == 832:
await asyncio.Event().wait()
return await super().make_request(bot, method, timeout)
stub = StallingTelegram()
bot = Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS", session=stub)
message = Message(message_id=1, date=datetime.now(timezone.utc), chat=Chat(id=831, type="private"),
from_user=TelegramUser(id=831, first_name="Sender", is_bot=False), text="hello").as_(bot)
_, successes, failures = await asyncio.wait_for(
chat_handlers.broadcast_message_with_scheduler(message, sender, exclude_user_id=831), 2)
assert successes >= 1 and failures == 1
assert await chat_handlers._copy_with_sender(message, 833, "Sender") == 100
async def test_blocked_recipient_and_extreme_flood_wait_do_not_stop_broadcast(monkeypatch):
service = BroadcastService()
async with async_session_maker() as session:
admin = await UserService.get_or_create_user(session, 900001)
users = [await UserService.get_or_create_user(session, number) for number in (834, 835, 836)]
async def copy_to(target):
method = CopyMessage(chat_id=target, from_chat_id=1, message_id=1)
if target == 834:
raise TelegramForbiddenError(method=method, message="bot was blocked by the user")
if target == 835:
raise TelegramRetryAfter(method=method, message="slow down", retry_after=3600)
return SimpleNamespace(message_id=1)
message = SimpleNamespace(copy_to=copy_to, text="test", caption=None, content_type="text",
photo=None, video=None, document=None, animation=None, voice=None, audio=None)
result = await asyncio.wait_for(service.broadcast_to_users(None, message, admin.id, users), 3)
assert result == {"total": 3, "success": 1, "failed": 2, "blocked": 1}
async def test_timeout_releases_delivery_capacity(monkeypatch):
monkeypatch.setattr(delivery, "DELIVERY_TIMEOUT", 0.05)
@delivery.background_delivery
async def stall():
await asyncio.Event().wait()
result = await asyncio.gather(*(stall() for _ in range(8)), return_exceptions=True)
assert all(isinstance(error, TimeoutError) for error in result)
sender = SimpleNamespace(send_message=AsyncMock(return_value=True))
assert await delivery.send_background(sender, chat_id=1, text="next")
async def test_background_job_failure_releases_slot_and_shutdown_cancels_pending_work():
failure_report = AsyncMock()
async def broken():
raise RuntimeError("synthetic failure")
assert start_broadcast(837, broken, failure_report)
assert not start_broadcast(837, broken, failure_report)
await asyncio.gather(*list(jobs().values()))
failure_report.assert_awaited_once()
assert not jobs()
assert start_broadcast(837, asyncio.Event().wait, failure_report)
await shutdown_broadcasts()
assert not jobs()
async def test_missing_broadcast_report_does_not_fail_completed_delivery():
from src.handlers.admin_panel import _edit_broadcast_status
report = SimpleNamespace(edit_text=AsyncMock(side_effect=TelegramForbiddenError(
method=CopyMessage(chat_id=1, from_chat_id=1, message_id=1), message="bot was blocked")))
await _edit_broadcast_status(report, "Delivery completed")
report.edit_text.assert_awaited_once()
async def test_admin_can_use_start_while_broadcast_runs_and_failure_clears_fsm(monkeypatch):
import main
from src.handlers import admin_panel
started = asyncio.Event()
finish = asyncio.Event()
async def sending(*args, **kwargs):
started.set()
await finish.wait()
raise RuntimeError("synthetic Telegram failure")
monkeypatch.setattr(admin_panel, "_broadcast_direct", sending)
await dispatch(event(900001, text="/cancel"))
state = main.dp.fsm.get_context(bot=Bot("123456:TEST_TOKEN_FOR_ISOLATED_TESTS"), chat_id=900001, user_id=900001)
await state.set_state(admin_panel.AdminStates.broadcast_message)
await state.set_data({"broadcast_type": "direct"})
try:
await dispatch(event(900001, text="broadcast content"))
await asyncio.wait_for(started.wait(), 1)
assert await state.get_state() is None
calls = await asyncio.wait_for(dispatch(event(900001, text="/start")), 2)
assert any((getattr(call, "text", "") or "").startswith("👋") for call in calls)
finish.set()
await asyncio.gather(*list(jobs().values()))
assert await state.get_state() is None
finally:
await shutdown_broadcasts()

View File

@@ -18,6 +18,7 @@ def test_release_contains_runtime_and_migrations_but_no_secrets(tmp_path):
names = archive.getnames()
assert "main.py" in names and "Dockerfile" in names
assert "migrations/versions/20260913_staff_concurrency.py" in names
assert {"docs/ADMIN_GUIDE.md", "docs/CASHIER_GUIDE.md", "docs/SYSTEM_ADMIN_GUIDE.md"} <= set(names)
assert not any(".env" in name or ".history" in name or ".git/" in name or "__pycache__" in name for name in names)

View File

@@ -0,0 +1,121 @@
import asyncio
from aiogram import Bot
import pytest
from sqlalchemy import select
from src.core.access import get_role
from src.core.database import async_session_maker
from src.core.models import User
from src.core.services import UserService
from src.core.staff_service import StaffError, change_role, get_member
from test_dispatcher import dispatch, event
async def create_user(telegram_id, **flags):
async with async_session_maker() as session:
user = await UserService.get_or_create_user(session, telegram_id, first_name="<Employee>")
for key, value in flags.items():
setattr(user, key, value)
await session.commit()
return user
async def test_roles_are_exclusive_and_removal_does_not_restore_old_cashier_privilege():
await create_user(810, is_cashier=True)
await change_role(900001, 810, "admin", (False, True))
member = await get_member(900001, 810)
assert member.role == "admin" and member.flags == (True, False)
await change_role(900001, 810, "cashier", member.flags)
assert await get_role(810) == "cashier"
await change_role(900001, 810, "user", (False, True))
assert await get_role(810) == "user"
@pytest.mark.parametrize("actor", [811, 900002])
async def test_non_system_staff_cannot_assign_roles_even_by_service(actor):
await create_user(actor, is_admin=actor == 811)
await create_user(812)
with pytest.raises(StaffError, match="системный администратор"):
await change_role(actor, 812, "admin", (False, False))
assert await get_role(812) == "user"
@pytest.mark.parametrize("target", [900001, 900002])
async def test_server_configured_staff_cannot_be_changed(target):
await create_user(target)
with pytest.raises(StaffError):
await change_role(900001, target, "user", (False, False))
async def test_two_system_admins_cannot_overwrite_a_changed_role(monkeypatch):
from src.core import staff_service
monkeypatch.setattr(staff_service, "ADMIN_IDS", [900001, 900003])
await create_user(813)
results = await asyncio.gather(
change_role(900001, 813, "admin", (False, False)),
change_role(900003, 813, "cashier", (False, False)), return_exceptions=True)
assert sum(isinstance(result, StaffError) for result in results) == 1
assert sum(result is None for result in results) == 1
@pytest.mark.parametrize("flags", [{"is_admin": True}, {"is_cashier": True}])
async def test_cleanup_cannot_revoke_staff_access(flags):
user = await create_user(814, **flags)
async with async_session_maker() as session:
assert not await UserService.delete_user(session, user.id)
assert await session.scalar(select(User.id).where(User.id == user.id)) == user.id
def confirmation(calls):
return next(button.callback_data for call in calls if getattr(call, "reply_markup", None)
for row in call.reply_markup.inline_keyboard for button in row
if button.callback_data and button.callback_data.startswith("staff:confirm:"))
async def test_actual_assignment_requires_confirmation_and_old_button_cannot_change_new_target():
await create_user(815)
await create_user(816)
first = await dispatch(event(900001, text="/add_admin 815"))
old_button = confirmation(first)
assert await get_role(815) == "user"
assert any("&lt;Employee&gt;" in (getattr(call, "text", "") or "") for call in first)
second = await dispatch(event(900001, text="/add_cashier 816"))
new_button = confirmation(second)
await dispatch(event(900001, callback_data=old_button))
assert await get_role(815) == "user" and await get_role(816) == "user"
await dispatch(event(900001, callback_data=new_button))
assert await get_role(816) == "cashier"
await change_role(900001, 816, "user", (False, True))
await dispatch(event(900001, callback_data=new_button))
assert await get_role(816) == "user"
@pytest.mark.parametrize("actor", [817, 900002])
async def test_actual_ordinary_admin_and_cashier_cannot_forge_staff_callback(actor):
await create_user(actor, is_admin=actor == 817)
await create_user(818)
for update in (event(actor, text="/add_admin 818"),
event(actor, callback_data="staff:role:admin"),
event(actor, callback_data="admin_remove_select:900001")):
calls = await dispatch(update)
assert any("системный администратор" in (getattr(call, "text", "") or "") for call in calls)
assert await get_role(818) == "user"
async def test_revoked_cashier_cannot_continue_open_participant_dialog():
await create_user(819, is_cashier=True)
await dispatch(event(819, callback_data="cash_add:1"))
await change_role(900001, 819, "user", (False, True))
calls = await dispatch(event(819, text="11-22-33-44-55-66-77"))
assert any("Недостаточно прав" in (getattr(call, "text", "") or "") for call in calls)
async def test_guides_are_delivered_only_to_the_appropriate_roles():
from src.handlers.operator_help import DOCS, GUIDES
assert all((DOCS / spec[0]).is_file() for spec in GUIDES.values())
calls = await dispatch(event(900002, text="/cashier_help"))
assert any(call.__api_method__ == "sendDocument" for call in calls)
for update in (event(900002, text="/admin_help"), event(820, callback_data="guide:system")):
calls = await dispatch(update)
assert not any(call.__api_method__ == "sendDocument" for call in calls)