Separate staff roles and protect message delivery with operator guides
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
126
tests/test_delivery_resilience.py
Normal file
126
tests/test_delivery_resilience.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
121
tests/test_staff_management.py
Normal file
121
tests/test_staff_management.py
Normal 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("<Employee>" 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)
|
||||
Reference in New Issue
Block a user