# backend/tests/test_schedule_permissions.py """ Тесты для системы прав доступа календарей django-scheduler. """ from datetime import timedelta import pytest from django.contrib.contenttypes.models import ContentType from django.contrib.auth import get_user_model from django.utils import timezone from rest_framework.test import APIClient from rest_framework import status from schedule.models import ( Calendar, CalendarRelation, Event, EventRelation, Occurrence, Rule, ) User = get_user_model() @pytest.fixture def api_client(): """API клиент для тестов.""" return APIClient() @pytest.fixture def user1(db): """Первый тестовый пользователь.""" return User.objects.create_user( username='user1', email='user1@example.com', password='testpass123', first_name='User', last_name='One', phone_number='+79991234567', # Правильное имя параметра is_active=True, ) @pytest.fixture def user2(db): """Второй тестовый пользователь.""" return User.objects.create_user( username='user2', email='user2@example.com', password='testpass123', first_name='User', last_name='Two', phone_number='+79991234568', is_active=True, ) @pytest.fixture def user3(db): """Третий тестовый пользователь.""" return User.objects.create_user( username='user3', email='user3@example.com', password='testpass123', first_name='User', last_name='Three', phone_number='+79991234569', is_active=True, ) @pytest.fixture def admin_user(db): """Администратор для проверки scope календарей.""" return User.objects.create_user( username="admin", email="admin@example.com", password="testpass123", first_name="Admin", last_name="User", phone_number="+79991234570", is_staff=True, is_superuser=True, email_verified=True, ) @pytest.mark.django_db class TestCalendarPermissions: """Тесты прав доступа к календарям.""" def test_create_calendar_creates_owner_relation(self, api_client, user1): """При создании календаря автоматически создается owner relation.""" api_client.force_authenticate(user=user1) response = api_client.post('/api/v1/schedule/calendars/', { 'name': 'Test Calendar', }) assert response.status_code == status.HTTP_201_CREATED calendar_id = response.data['id'] # Проверяем, что создался CalendarRelation с owner ct = ContentType.objects.get_for_model(User) relation = CalendarRelation.objects.get( calendar_id=calendar_id, content_type=ct, object_id=user1.id ) assert relation.distinction == 'owner' assert relation.inheritable is True def test_get_calendars_shows_only_accessible(self, api_client, user1, user2): """Пользователь видит только свои календари.""" api_client.force_authenticate(user=user1) # user1 создает календарь response = api_client.post('/api/v1/schedule/calendars/', { 'name': 'User1 Calendar', }) assert response.status_code == status.HTTP_201_CREATED # user2 создает свой календарь api_client.force_authenticate(user=user2) response = api_client.post('/api/v1/schedule/calendars/', { 'name': 'User2 Calendar', }) assert response.status_code == status.HTTP_201_CREATED # user1 видит только свой календарь api_client.force_authenticate(user=user1) response = api_client.get('/api/v1/schedule/calendars/') assert response.status_code == status.HTTP_200_OK if isinstance(response.data, dict) and 'results' in response.data: calendars = response.data['results'] else: calendars = response.data assert len(calendars) == 1 assert calendars[0]['name'] == 'User1 Calendar' def test_add_participant_to_calendar(self, api_client, user1, user2): """Owner может добавить участника к календарю.""" api_client.force_authenticate(user=user1) # Создаем календарь response = api_client.post('/api/v1/schedule/calendars/', { 'name': 'Shared Calendar', }) calendar_id = response.data['id'] # Добавляем user2 как viewer response = api_client.post( f'/api/v1/schedule/calendars/{calendar_id}/add-participant/', {'user_id': user2.id, 'role': 'viewer'} ) assert response.status_code == status.HTTP_201_CREATED assert response.data['user']['id'] == user2.id assert response.data['distinction'] == 'viewer' # Теперь user2 должен видеть календарь api_client.force_authenticate(user=user2) response = api_client.get('/api/v1/schedule/calendars/') if isinstance(response.data, dict) and 'results' in response.data: calendars = response.data['results'] else: calendars = response.data assert len(calendars) == 1 assert calendars[0]['id'] == calendar_id def test_cannot_add_inactive_participant_to_calendar( self, api_client, user1, user2 ): """Уволенного сотрудника нельзя заново добавить в календарь.""" api_client.force_authenticate(user=user1) calendar_response = api_client.post( '/api/v1/schedule/calendars/', {'name': 'Active participants only'}, ) user2.is_active = False user2.save(update_fields=['is_active']) response = api_client.post( f"/api/v1/schedule/calendars/{calendar_response.data['id']}/add-participant/", {'user_id': user2.id, 'role': 'viewer'}, ) assert response.status_code == status.HTTP_400_BAD_REQUEST assert 'user_id' in response.data def test_non_owner_cannot_add_participant(self, api_client, user1, user2, user3): """Не-owner не может добавлять участников.""" api_client.force_authenticate(user=user1) # user1 создает календарь response = api_client.post('/api/v1/schedule/calendars/', { 'name': 'Test Calendar', }) calendar_id = response.data['id'] # Добавляем user2 как editor api_client.post( f'/api/v1/schedule/calendars/{calendar_id}/add-participant/', {'user_id': user2.id, 'role': 'editor'} ) # user2 пытается добавить user3 - должно быть запрещено api_client.force_authenticate(user=user2) response = api_client.post( f'/api/v1/schedule/calendars/{calendar_id}/add-participant/', {'user_id': user3.id, 'role': 'viewer'} ) assert response.status_code == status.HTTP_403_FORBIDDEN def test_remove_participant_from_calendar(self, api_client, user1, user2): """Owner может удалить участника из календаря.""" api_client.force_authenticate(user=user1) # Создаем календарь и добавляем user2 response = api_client.post('/api/v1/schedule/calendars/', { 'name': 'Test Calendar', }) calendar_id = response.data['id'] api_client.post( f'/api/v1/schedule/calendars/{calendar_id}/add-participant/', {'user_id': user2.id, 'role': 'viewer'} ) # Удаляем user2 response = api_client.delete( f'/api/v1/schedule/calendars/{calendar_id}/remove-participant/{user2.id}/' ) assert response.status_code == status.HTTP_204_NO_CONTENT # user2 больше не видит календарь api_client.force_authenticate(user=user2) response = api_client.get('/api/v1/schedule/calendars/') if isinstance(response.data, dict) and 'results' in response.data: calendars = response.data['results'] else: calendars = response.data assert len(calendars) == 0 def test_cannot_remove_owner(self, api_client, user1): """Нельзя удалить owner из календаря.""" api_client.force_authenticate(user=user1) # Создаем календарь response = api_client.post('/api/v1/schedule/calendars/', { 'name': 'Test Calendar', }) calendar_id = response.data['id'] # Пытаемся удалить себя (owner) response = api_client.delete( f'/api/v1/schedule/calendars/{calendar_id}/remove-participant/{user1.id}/' ) assert response.status_code == status.HTTP_400_BAD_REQUEST def test_list_participants(self, api_client, user1, user2, user3): """Получение списка участников календаря.""" api_client.force_authenticate(user=user1) # Создаем календарь response = api_client.post('/api/v1/schedule/calendars/', { 'name': 'Test Calendar', }) calendar_id = response.data['id'] # Добавляем участников api_client.post( f'/api/v1/schedule/calendars/{calendar_id}/add-participant/', {'user_id': user2.id, 'role': 'editor'} ) api_client.post( f'/api/v1/schedule/calendars/{calendar_id}/add-participant/', {'user_id': user3.id, 'role': 'viewer'} ) # Получаем список участников response = api_client.get( f'/api/v1/schedule/calendars/{calendar_id}/participants/' ) assert response.status_code == status.HTTP_200_OK assert len(response.data) == 3 # owner + 2 участника # Проверяем роли roles = {p['user']['id']: p['distinction'] for p in response.data} assert roles[user1.id] == 'owner' assert roles[user2.id] == 'editor' assert roles[user3.id] == 'viewer' def test_admin_list_scope_defaults_to_accessible_only( self, api_client, admin_user, user1 ): """По умолчанию админ видит свои доступные календари, а не весь реестр.""" api_client.force_authenticate(user=admin_user) admin_calendar_response = api_client.post( '/api/v1/schedule/calendars/', {'name': 'Admin Calendar'} ) assert admin_calendar_response.status_code == status.HTTP_201_CREATED admin_calendar_id = admin_calendar_response.data['id'] api_client.force_authenticate(user=user1) other_calendar_response = api_client.post( '/api/v1/schedule/calendars/', {'name': 'Other Calendar'} ) assert other_calendar_response.status_code == status.HTTP_201_CREATED other_calendar_id = other_calendar_response.data['id'] api_client.force_authenticate(user=admin_user) default_response = api_client.get('/api/v1/schedule/calendars/') default_calendars = default_response.data assert {cal['id'] for cal in default_calendars} == {admin_calendar_id} full_response = api_client.get('/api/v1/schedule/calendars/?scope=all') full_calendars = full_response.data assert {cal['id'] for cal in full_calendars} == { admin_calendar_id, other_calendar_id, } @pytest.mark.django_db class TestRecurringEvents: """Тесты повторяющихся событий.""" def test_admin_event_scope_matches_calendar_scope( self, api_client, admin_user, user1 ): """У admin default list/occurrences не должны тянуть чужие календари.""" api_client.force_authenticate(user=admin_user) admin_calendar_response = api_client.post( '/api/v1/schedule/calendars/', {'name': 'Admin Calendar'} ) admin_calendar_id = admin_calendar_response.data['id'] api_client.force_authenticate(user=user1) other_calendar_response = api_client.post( '/api/v1/schedule/calendars/', {'name': 'Other Calendar'} ) other_calendar_id = other_calendar_response.data['id'] start = timezone.now().replace(microsecond=0, second=0) range_end = start + timedelta(hours=2) Event.objects.create( calendar_id=admin_calendar_id, title='Admin event', start=start, end=start + timedelta(hours=1), creator=admin_user, ) Event.objects.create( calendar_id=other_calendar_id, title='Other event', start=start, end=start + timedelta(hours=1), creator=user1, ) api_client.force_authenticate(user=admin_user) default_events = api_client.get( '/api/v1/schedule/events/', { 'start': start.isoformat(), 'end': range_end.isoformat(), }, ) assert default_events.status_code == status.HTTP_200_OK default_events_list = ( default_events.data['results'] if isinstance(default_events.data, dict) else default_events.data ) assert {event['title'] for event in default_events_list} == {'Admin event'} all_events = api_client.get( '/api/v1/schedule/events/', { 'scope': 'all', 'start': start.isoformat(), 'end': range_end.isoformat(), }, ) assert all_events.status_code == status.HTTP_200_OK all_events_list = ( all_events.data['results'] if isinstance(all_events.data, dict) else all_events.data ) assert {event['title'] for event in all_events_list} == { 'Admin event', 'Other event', } default_occurrences = api_client.get( '/api/v1/schedule/events/occurrences/', { 'start': start.isoformat(), 'end': range_end.isoformat(), }, ) assert default_occurrences.status_code == status.HTTP_200_OK assert {event['title'] for event in default_occurrences.data} == {'Admin event'} all_occurrences = api_client.get( '/api/v1/schedule/events/occurrences/', { 'scope': 'all', 'start': start.isoformat(), 'end': range_end.isoformat(), }, ) assert all_occurrences.status_code == status.HTTP_200_OK assert {event['title'] for event in all_occurrences.data} == { 'Admin event', 'Other event', } def test_create_rule(self, api_client, user1): """Создание правила повторения.""" api_client.force_authenticate(user=user1) response = api_client.post('/api/v1/schedule/rules/', { 'name': 'Weekly', 'description': 'Every week on Monday', 'frequency': 'WEEKLY', 'params': 'byweekday:0' # Monday }) assert response.status_code == status.HTTP_201_CREATED assert response.data['frequency'] == 'WEEKLY' assert response.data['frequency_display'] == 'Еженедельно' # Django локализация def test_create_recurring_event(self, api_client, user1): """Создание события с повторением.""" api_client.force_authenticate(user=user1) # Создаем календарь cal_response = api_client.post('/api/v1/schedule/calendars/', { 'name': 'Test Calendar', }) calendar_id = cal_response.data['id'] # Создаем правило rule_response = api_client.post('/api/v1/schedule/rules/', { 'name': 'Daily', 'description': 'Every day', 'frequency': 'DAILY', 'params': '' }) rule_id = rule_response.data['id'] # Создаем событие с правилом from datetime import datetime, timedelta from django.utils import timezone start = timezone.now() end = start + timedelta(hours=1) end_recurring = start + timedelta(days=30) response = api_client.post('/api/v1/schedule/events/', { 'title': 'Daily Meeting', 'description': 'Team standup', 'start': start.isoformat(), 'end': end.isoformat(), 'calendar': calendar_id, 'rule': rule_id, 'end_recurring_period': end_recurring.isoformat(), }) assert response.status_code == status.HTTP_201_CREATED assert response.data['rule'] == rule_id assert response.data['end_recurring_period'] is not None def test_get_occurrences(self, api_client, user1): """Получение материализованных вхождений для recurring события.""" api_client.force_authenticate(user=user1) # Создаем календарь cal_response = api_client.post('/api/v1/schedule/calendars/', { 'name': 'Test Calendar', }) calendar_id = cal_response.data['id'] # Создаем правило - еженедельно rule_response = api_client.post('/api/v1/schedule/rules/', { 'name': 'Weekly', 'description': 'Every week', 'frequency': 'WEEKLY', 'params': '' }) rule_id = rule_response.data['id'] # Создаем событие from datetime import datetime, timedelta from django.utils import timezone start = timezone.now() end = start + timedelta(hours=1) end_recurring = start + timedelta(weeks=4) api_client.post('/api/v1/schedule/events/', { 'title': 'Weekly Meeting', 'start': start.isoformat(), 'end': end.isoformat(), 'calendar': calendar_id, 'rule': rule_id, 'end_recurring_period': end_recurring.isoformat(), }) # Получаем occurrences response = api_client.get( '/api/v1/schedule/events/occurrences/', { 'calendar': calendar_id, 'start': start.isoformat(), 'end': (start + timedelta(weeks=5)).isoformat(), } ) assert response.status_code == status.HTTP_200_OK # Должно быть ~4 вхождения (еженедельно в течение 4 недель) assert len(response.data) >= 4 @pytest.mark.django_db class TestiCalendarExport: """Тесты экспорта в iCalendar формат.""" def test_export_calendar_to_ics(self, api_client, user1): """Экспорт календаря в .ics файл.""" api_client.force_authenticate(user=user1) # Создаем календарь cal_response = api_client.post('/api/v1/schedule/calendars/', { 'name': 'Test Calendar', }) calendar_id = cal_response.data['id'] # Добавляем событие from datetime import datetime, timedelta from django.utils import timezone start = timezone.now() end = start + timedelta(hours=2) api_client.post('/api/v1/schedule/events/', { 'title': 'Test Event', 'description': 'Test Description', 'start': start.isoformat(), 'end': end.isoformat(), 'calendar': calendar_id, }) # Экспортируем календарь response = api_client.get( f'/api/v1/schedule/calendars/{calendar_id}/export-ical/' ) assert response.status_code == status.HTTP_200_OK assert response['Content-Type'] == 'text/calendar' assert 'Content-Disposition' in response assert '.ics' in response['Content-Disposition'] # Проверяем содержимое content = response.content.decode('utf-8') assert 'BEGIN:VCALENDAR' in content assert 'BEGIN:VEVENT' in content assert 'Test Event' in content assert 'END:VEVENT' in content assert 'END:VCALENDAR' in content @pytest.mark.django_db class TestEventRelations: """Тесты участников событий.""" def test_add_participant_to_event(self, api_client, user1, user2): """Добавление участника к событию.""" api_client.force_authenticate(user=user1) # Создаем календарь cal_response = api_client.post('/api/v1/schedule/calendars/', { 'name': 'Test Calendar', }) calendar_id = cal_response.data['id'] # Создаем событие from datetime import datetime, timedelta from django.utils import timezone start = timezone.now() end = start + timedelta(hours=1) event_response = api_client.post('/api/v1/schedule/events/', { 'title': 'Meeting', 'start': start.isoformat(), 'end': end.isoformat(), 'calendar': calendar_id, }) event_id = event_response.data['id'] # Добавляем user2 как участника ct = ContentType.objects.get_for_model(User) response = api_client.post('/api/v1/schedule/relations/', { 'event': event_id, 'content_type': ct.id, 'object_id': user2.id, 'distinction': 'attendee', }) assert response.status_code == status.HTTP_201_CREATED assert response.data['event'] == event_id def test_list_event_participants(self, api_client, user1, user2, user3): """Получение списка участников события.""" api_client.force_authenticate(user=user1) # Создаем календарь и событие cal_response = api_client.post('/api/v1/schedule/calendars/', { 'name': 'Test Calendar', }) calendar_id = cal_response.data['id'] from datetime import datetime, timedelta from django.utils import timezone start = timezone.now() end = start + timedelta(hours=1) event_response = api_client.post('/api/v1/schedule/events/', { 'title': 'Team Meeting', 'start': start.isoformat(), 'end': end.isoformat(), 'calendar': calendar_id, }) event_id = event_response.data['id'] # Добавляем участников ct = ContentType.objects.get_for_model(User) api_client.post('/api/v1/schedule/relations/', { 'event': event_id, 'content_type': ct.id, 'object_id': user2.id, 'distinction': 'attendee', }) api_client.post('/api/v1/schedule/relations/', { 'event': event_id, 'content_type': ct.id, 'object_id': user3.id, 'distinction': 'attendee', }) # Получаем список участников response = api_client.get( '/api/v1/schedule/relations/', {'event': event_id} ) assert response.status_code == status.HTTP_200_OK if isinstance(response.data, dict) and 'results' in response.data: relations = response.data['results'] else: relations = response.data assert len(relations) == 2 @pytest.mark.django_db class TestScheduleObjectBoundaries: """Низкоуровневые endpoints не обходят доступ к событию.""" def test_occurrences_relations_and_rules_respect_event_access( self, api_client, user1, user2 ): calendar = Calendar.objects.create( name="Private calendar", slug="private-calendar", ) user_ct = ContentType.objects.get_for_model(User) CalendarRelation.objects.create( calendar=calendar, content_type=user_ct, object_id=user1.id, distinction="owner", inheritable=True, ) rule = Rule.objects.create( name="Private daily", description="Private recurrence", frequency="DAILY", params="", ) start = timezone.now().replace(microsecond=0) event = Event.objects.create( calendar=calendar, creator=user1, rule=rule, title="Private event", start=start, end=start + timedelta(hours=1), end_recurring_period=start + timedelta(days=3), ) occurrence = Occurrence.objects.create( event=event, title=event.title, description="", start=start, end=start + timedelta(hours=1), original_start=start, original_end=start + timedelta(hours=1), ) api_client.force_authenticate(user=user2) occurrence_response = api_client.get("/api/v1/schedule/occurrences/") assert occurrence_response.status_code == status.HTTP_200_OK occurrence_results = ( occurrence_response.data.get("results", []) if isinstance(occurrence_response.data, dict) else occurrence_response.data ) assert all(item["id"] != occurrence.id for item in occurrence_results) relation_response = api_client.get( "/api/v1/schedule/relations/", {"event": event.id} ) assert relation_response.status_code == status.HTTP_200_OK relation_results = ( relation_response.data.get("results", []) if isinstance(relation_response.data, dict) else relation_response.data ) assert relation_results == [] forbidden_relation = api_client.post( "/api/v1/schedule/relations/", { "event": event.id, "content_type": user_ct.id, "object_id": user2.id, "distinction": "attendee", }, ) assert forbidden_relation.status_code == status.HTTP_403_FORBIDDEN forbidden_rule_update = api_client.patch( f"/api/v1/schedule/rules/{rule.id}/", {"description": "Leaked update"}, format="json", ) assert forbidden_rule_update.status_code == status.HTTP_403_FORBIDDEN EventRelation.objects.create( event=event, content_type=user_ct, object_id=user2.id, distinction="attendee", ) assert ( api_client.get(f"/api/v1/schedule/events/{event.id}/").status_code == status.HTTP_200_OK ) visible_occurrences = api_client.get( "/api/v1/schedule/occurrences/", {"event": event.id} ) visible_results = ( visible_occurrences.data.get("results", []) if isinstance(visible_occurrences.data, dict) else visible_occurrences.data ) assert any(item["id"] == occurrence.id for item in visible_results)