diff --git a/src/apps/api/tests/test_throttling.py b/src/apps/api/tests/test_throttling.py new file mode 100644 index 000000000..2cd3e46a5 --- /dev/null +++ b/src/apps/api/tests/test_throttling.py @@ -0,0 +1,137 @@ +from unittest.mock import patch + +from django.core.cache import cache +from django.test import TestCase, override_settings +from rest_framework import status +from rest_framework.test import APIClient +from rest_framework.throttling import SimpleRateThrottle + +from factories import CompetitionFactory, UserFactory + +# SimpleRateThrottle subclasses (AnonRateThrottle/UserRateThrottle/ScopedRateThrottle and +# their AnonBurstRateThrottle/UserBurstRateThrottle counterparts in api/throttling.py) read +# REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] once at import time into this shared class +# attribute, so @override_settings(REST_FRAMEWORK=...) does not reach it. Tests patch it +# directly to get small, fast limits instead of waiting out the real ones. +# +# Every scope referenced by an active throttle class must be present in the patched dict +# (missing keys raise ImproperlyConfigured), so tests start from generous defaults for all +# scopes and only tighten the one(s) under test. +PUBLIC_URL = "/api/competitions/public/" +LIST_URL = "/api/competitions/" + +GENEROUS_RATES = { + "anon": "1000/day", + "user": "1000/day", + "anon_burst": "1000/min", + "user_burst": "1000/min", + "competitions_public": "1000/day", +} + + +@override_settings(CACHES={ + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "throttle-test-cache", + }, +}) +class ThrottlingTests(TestCase): + def setUp(self): + self.client = APIClient() + cache.clear() + CompetitionFactory(published=True) + + def tearDown(self): + cache.clear() + + @patch.object(SimpleRateThrottle, "THROTTLE_RATES", {**GENEROUS_RATES, "competitions_public": "2/min"}) + def test_public_action_throttles_after_scope_limit(self): + for _ in range(2): + response = self.client.get(PUBLIC_URL) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = self.client.get(PUBLIC_URL) + self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) + self.assertIn("Retry-After", response) + + @patch.object(SimpleRateThrottle, "THROTTLE_RATES", {**GENEROUS_RATES, "competitions_public": "1/min"}) + def test_public_action_throttle_is_independent_of_global_anon_throttle(self): + # Exhaust the competitions_public scope only. + self.assertEqual(self.client.get(PUBLIC_URL).status_code, status.HTTP_200_OK) + self.assertEqual(self.client.get(PUBLIC_URL).status_code, status.HTTP_429_TOO_MANY_REQUESTS) + + # The global 'anon' bucket (used by list/retrieve) is untouched. + response = self.client.get(LIST_URL) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @patch.object(SimpleRateThrottle, "THROTTLE_RATES", {**GENEROUS_RATES, "anon": "2/min"}) + def test_list_action_uses_global_anon_throttle(self): + for _ in range(2): + response = self.client.get(LIST_URL) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = self.client.get(LIST_URL) + self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) + + @patch.object(SimpleRateThrottle, "THROTTLE_RATES", {**GENEROUS_RATES, "user": "2/min"}) + def test_list_action_uses_global_user_throttle_for_authenticated_requests(self): + self.client.force_authenticate(user=UserFactory(username="throttled-user")) + + for _ in range(2): + response = self.client.get(LIST_URL) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = self.client.get(LIST_URL) + self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) + + @patch.object(SimpleRateThrottle, "THROTTLE_RATES", {**GENEROUS_RATES, "competitions_public": "1/min"}) + def test_public_action_counts_anonymous_and_authenticated_requests_separately(self): + # Anonymous identity (keyed by IP) exhausts its own bucket. + self.assertEqual(self.client.get(PUBLIC_URL).status_code, status.HTTP_200_OK) + self.assertEqual(self.client.get(PUBLIC_URL).status_code, status.HTTP_429_TOO_MANY_REQUESTS) + + # A different, authenticated identity (keyed by user id) has its own separate bucket. + self.client.force_authenticate(user=UserFactory(username="another-user")) + self.assertEqual(self.client.get(PUBLIC_URL).status_code, status.HTTP_200_OK) + + @patch.object(SimpleRateThrottle, "THROTTLE_RATES", {**GENEROUS_RATES, "anon_burst": "2/min"}) + def test_list_action_uses_global_anon_burst_throttle(self): + for _ in range(2): + response = self.client.get(LIST_URL) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = self.client.get(LIST_URL) + self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) + + @patch.object(SimpleRateThrottle, "THROTTLE_RATES", {**GENEROUS_RATES, "user_burst": "2/min"}) + def test_list_action_uses_global_user_burst_throttle_for_authenticated_requests(self): + self.client.force_authenticate(user=UserFactory(username="burst-user")) + + for _ in range(2): + response = self.client.get(LIST_URL) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = self.client.get(LIST_URL) + self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) + + @patch.object(SimpleRateThrottle, "THROTTLE_RATES", {**GENEROUS_RATES, "anon_burst": "2/min"}) + def test_public_action_throttled_by_anon_burst_even_with_generous_daily_scope(self): + # competitions_public (daily) has plenty of room; the burst throttle stacked + # onto the `public` action (api/views/competitions.py) should still trip. + for _ in range(2): + response = self.client.get(PUBLIC_URL) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = self.client.get(PUBLIC_URL) + self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) + + @patch.object(SimpleRateThrottle, "THROTTLE_RATES", {**GENEROUS_RATES, "user_burst": "2/min"}) + def test_public_action_throttled_by_user_burst_even_with_generous_daily_scope(self): + self.client.force_authenticate(user=UserFactory(username="public-burst-user")) + + for _ in range(2): + response = self.client.get(PUBLIC_URL) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = self.client.get(PUBLIC_URL) + self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) diff --git a/src/apps/api/throttling.py b/src/apps/api/throttling.py new file mode 100644 index 000000000..1fffe4187 --- /dev/null +++ b/src/apps/api/throttling.py @@ -0,0 +1,9 @@ +from rest_framework.throttling import AnonRateThrottle, UserRateThrottle + + +class AnonBurstRateThrottle(AnonRateThrottle): + scope = 'anon_burst' + + +class UserBurstRateThrottle(UserRateThrottle): + scope = 'user_burst' diff --git a/src/apps/api/views/competitions.py b/src/apps/api/views/competitions.py index a8e4c1b49..797c83f4e 100644 --- a/src/apps/api/views/competitions.py +++ b/src/apps/api/views/competitions.py @@ -17,7 +17,9 @@ from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.renderers import JSONRenderer +from rest_framework.throttling import ScopedRateThrottle from rest_framework_csv.renderers import CSVRenderer +from api.throttling import AnonBurstRateThrottle, UserBurstRateThrottle from api.pagination import DynamicChoicePagination, LargePagination from api.renderers import ZipRenderer from rest_framework.viewsets import ModelViewSet @@ -42,6 +44,10 @@ class CompetitionViewSet(ModelViewSet): queryset = Competition.objects.all() permission_classes = (AllowAny,) + # Declared so the `public` action's @action(throttle_scope=...) kwarg passes + # ViewSetMixin.as_view()'s hasattr(cls, key) check; ScopedRateThrottle reads it + # via getattr(view, 'throttle_scope', None) regardless of this default. + throttle_scope = None def get_queryset(self): @@ -568,7 +574,12 @@ def create_dump(self, request, pk=None): serializer = CompetitionCreationTaskStatusSerializer({"status": "Success. Competition dump is being created."}) return Response(serializer.data, status=201) - @action(detail=False, methods=('GET',), pagination_class=LargePagination) + @action( + detail=False, methods=('GET',), + pagination_class=LargePagination, + throttle_classes=[ScopedRateThrottle, AnonBurstRateThrottle, UserBurstRateThrottle], + throttle_scope='competitions_public', + ) def public(self, request): """ Retrieve a public list of published competitions with optional filtering and ordering. diff --git a/src/settings/base.py b/src/settings/base.py index 3f39a2448..3d1fe15f6 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -312,6 +312,19 @@ 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticated', ), + 'DEFAULT_THROTTLE_CLASSES': ( + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.UserRateThrottle', + 'api.throttling.AnonBurstRateThrottle', + 'api.throttling.UserBurstRateThrottle', + ), + 'DEFAULT_THROTTLE_RATES': { + 'anon': '100/day', + 'user': '1000/day', + 'anon_burst': '60/min', + 'user_burst': '300/min', + 'competitions_public': '300/day', + }, 'DATETIME_INPUT_FORMATS': ( 'iso-8601', '%B %d, %Y',