From 25acbb3cf30226a73c98d95b0b3af2fc60200344 Mon Sep 17 00:00:00 2001 From: Nicole Xu Date: Fri, 28 Nov 2025 02:02:29 -0500 Subject: [PATCH 1/8] add input validation for team eligibility changes --- backend/siarnaq/api/episodes/admin.py | 6 +- .../0012_eligibilitycriterion_is_private.py | 18 ++++++ backend/siarnaq/api/episodes/models.py | 3 + backend/siarnaq/api/teams/tests.py | 62 ++++++++++++++++++- backend/siarnaq/api/teams/views.py | 24 +++++++ 5 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 backend/siarnaq/api/episodes/migrations/0012_eligibilitycriterion_is_private.py diff --git a/backend/siarnaq/api/episodes/admin.py b/backend/siarnaq/api/episodes/admin.py index f4b457021..baab6d9ad 100644 --- a/backend/siarnaq/api/episodes/admin.py +++ b/backend/siarnaq/api/episodes/admin.py @@ -110,9 +110,9 @@ class MapAdmin(admin.ModelAdmin): @admin.register(EligibilityCriterion) class EligibilityCriterionAdmin(admin.ModelAdmin): - fields = ("title", "description", "icon") - list_display = ("title", "icon") - ordering = ("title",) + fields = ("title", "description", "icon", "is_private") + list_display = ("title", "icon", "is_private") + ordering = ("is_private", "title") search_fields = ("title",) search_help_text = "Search for a title." diff --git a/backend/siarnaq/api/episodes/migrations/0012_eligibilitycriterion_is_private.py b/backend/siarnaq/api/episodes/migrations/0012_eligibilitycriterion_is_private.py new file mode 100644 index 000000000..c07d9c588 --- /dev/null +++ b/backend/siarnaq/api/episodes/migrations/0012_eligibilitycriterion_is_private.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.2 on 2025-11-04 21:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("episodes", "0011_episode_ranked_scrimmage_hourly_limit_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="eligibilitycriterion", + name="is_private", + field=models.BooleanField(default=True), + ), + ] diff --git a/backend/siarnaq/api/episodes/models.py b/backend/siarnaq/api/episodes/models.py index 3043365da..ff4a6c580 100644 --- a/backend/siarnaq/api/episodes/models.py +++ b/backend/siarnaq/api/episodes/models.py @@ -40,6 +40,9 @@ class EligibilityCriterion(models.Model): icon = models.CharField(max_length=8) """An icon to display for teams that satisfy this criterion.""" + is_private = models.BooleanField(default=True) + """Whether this criterion can only be assigned by an admin.""" + def __str__(self): return self.title diff --git a/backend/siarnaq/api/teams/tests.py b/backend/siarnaq/api/teams/tests.py index 990b95537..365e29557 100644 --- a/backend/siarnaq/api/teams/tests.py +++ b/backend/siarnaq/api/teams/tests.py @@ -3,10 +3,13 @@ from unittest.mock import patch from django.test import TestCase +from django.urls import reverse from django.utils import timezone +from rest_framework import status +from rest_framework.test import APITestCase from siarnaq.api.compete.models import Match, MatchParticipant, Submission -from siarnaq.api.episodes.models import Episode, Language, Map +from siarnaq.api.episodes.models import EligibilityCriterion, Episode, Language, Map from siarnaq.api.teams.managers import generate_4regular_graph from siarnaq.api.teams.models import Team, TeamStatus from siarnaq.api.user.models import User @@ -235,3 +238,60 @@ def test_map_not_public(self): Team.objects.autoscrim(episode=e1, best_of=3) m.refresh_from_db() self.assertFalse(m.matches.exists()) + + +class EligibilityTestCase(APITestCase): + """Test suite for team eligibility logic in Team API.""" + + def setUp(self): + self.episode = Episode.objects.create( + name_short="ep", + registration=timezone.now(), + game_release=timezone.now(), + game_archive=timezone.now(), + language=Language.JAVA_8, + ) + + self.team = Team.objects.create(episode=self.episode, name="t1") + self.user = User.objects.create_user(username="u1", email="u1@example.com") + self.team.members.add(self.user) + + # Partitions for: me (patch) + # eligible_for: contains private criteria, doesn't contain private criteria + # eligible_for: criteria all in team's assigned episode, or not + def test_patch_criterion_private(self): + criterion1 = EligibilityCriterion.objects.create( + title="crit1", description="desc1", icon="i1", is_private=False + ) + criterion2 = EligibilityCriterion.objects.create( + title="crit2", description="desc2", icon="i2", is_private=True + ) + + self.episode.eligibility_criteria.add(criterion1) + self.episode.eligibility_criteria.add(criterion2) + self.client.force_authenticate(self.user) + response = self.client.patch( + reverse("team-me", kwargs={"episode_id": "ep"}), + {"profile": {"eligible_for": [criterion1.pk]}}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = self.client.patch( + reverse("team-me", kwargs={"episode_id": "ep"}), + {"profile": {"eligible_for": [criterion1.pk, criterion2.pk]}}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_patch_criterion_wrong_episode(self): + criterion = EligibilityCriterion.objects.create( + title="crit", description="desc", icon="i", is_private=False + ) + self.client.force_authenticate(self.user) + response = self.client.patch( + reverse("team-me", kwargs={"episode_id": "ep"}), + {"profile": {"eligible_for": [criterion.pk]}}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/siarnaq/api/teams/views.py b/backend/siarnaq/api/teams/views.py index fd9fd8c64..5b9783c15 100644 --- a/backend/siarnaq/api/teams/views.py +++ b/backend/siarnaq/api/teams/views.py @@ -9,9 +9,11 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import filters, mixins, status, viewsets from rest_framework.decorators import action +from rest_framework.exceptions import APIException from rest_framework.permissions import IsAdminUser, IsAuthenticated from rest_framework.response import Response +from siarnaq.api.episodes.models import EligibilityCriterion from siarnaq.api.episodes.permissions import IsEpisodeAvailable from siarnaq.api.teams.exceptions import TeamMaxSizeExceeded from siarnaq.api.teams.filters import ( @@ -38,6 +40,12 @@ logger = structlog.get_logger(__name__) +class ForbiddenEligiblity(APIException): + status_code = status.HTTP_403_FORBIDDEN + default_detail = "You are not permitted to select these eligibility criteria." + default_code = "forbidden_eligibility" + + @extend_schema( parameters=[ OpenApiParameter( @@ -126,6 +134,22 @@ def me(self, request, *, episode_id): serializer.save() return Response(serializer.data) case "patch": + if request.data["profile"] is not None: + # If user is editing their profile, verify that they've + # selected valid eligibility criteria. + if eligible_for := request.data["profile"]["eligible_for"]: + selected_criteria = EligibilityCriterion.objects.filter( + pk__in=eligible_for + ) + episode_criteria = team.episode.eligibility_criteria.filter( + pk__in=eligible_for + ) + if any( + criterion.is_private or criterion not in episode_criteria + for criterion in selected_criteria + ): + raise ForbiddenEligiblity + serializer = self.get_serializer(team, data=request.data, partial=True) serializer.is_valid(raise_exception=True) serializer.save() From 8375d453496b47947f85b5d2b75e7a22c3820a4a Mon Sep 17 00:00:00 2001 From: Lowell Torola Date: Sun, 30 Nov 2025 16:39:58 -0500 Subject: [PATCH 2/8] wild swing at a fix --- backend/siarnaq/api/compete/test_views.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/siarnaq/api/compete/test_views.py b/backend/siarnaq/api/compete/test_views.py index fe761ccab..3ffe780d4 100644 --- a/backend/siarnaq/api/compete/test_views.py +++ b/backend/siarnaq/api/compete/test_views.py @@ -481,7 +481,7 @@ def test_not_admin_has_self_has_staff_team(self): match.maps.add(self.map) data = serializer.to_representation(match) self.assertEqual( - data, + dict(data), { "id": match.pk, "status": str(match.status), @@ -712,7 +712,7 @@ def test_not_admin_has_self_no_staff_team_tournament_none(self): match.maps.add(self.map) data = serializer.to_representation(match) self.assertEqual( - data, + dict(data), { "id": match.pk, "status": str(match.status), @@ -783,7 +783,7 @@ def test_not_admin_no_self_has_staff_team(self): match.maps.add(self.map) data = serializer.to_representation(match) self.assertEqual( - data, + dict(data), { "id": match.pk, "status": str(match.status), @@ -854,7 +854,7 @@ def test_not_admin_no_self_no_staff_team(self): match.maps.add(self.map) data = serializer.to_representation(match) self.assertEqual( - data, + dict(data), { "id": match.pk, "status": str(match.status), From 305188720d3ae592cd3d4862fd5defd47ddf9f9f Mon Sep 17 00:00:00 2001 From: Lowell Torola Date: Sun, 30 Nov 2025 16:47:57 -0500 Subject: [PATCH 3/8] deep equal --- backend/siarnaq/api/compete/test_views.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/siarnaq/api/compete/test_views.py b/backend/siarnaq/api/compete/test_views.py index 3ffe780d4..b9f5a5f30 100644 --- a/backend/siarnaq/api/compete/test_views.py +++ b/backend/siarnaq/api/compete/test_views.py @@ -1,4 +1,5 @@ import io +import json import random from datetime import timedelta from unittest.mock import mock_open, patch @@ -481,7 +482,7 @@ def test_not_admin_has_self_has_staff_team(self): match.maps.add(self.map) data = serializer.to_representation(match) self.assertEqual( - dict(data), + json.loads(json.dumps(data)), { "id": match.pk, "status": str(match.status), @@ -712,7 +713,7 @@ def test_not_admin_has_self_no_staff_team_tournament_none(self): match.maps.add(self.map) data = serializer.to_representation(match) self.assertEqual( - dict(data), + json.loads(json.dumps(data)), { "id": match.pk, "status": str(match.status), @@ -783,7 +784,7 @@ def test_not_admin_no_self_has_staff_team(self): match.maps.add(self.map) data = serializer.to_representation(match) self.assertEqual( - dict(data), + json.loads(json.dumps(data)), { "id": match.pk, "status": str(match.status), @@ -854,7 +855,7 @@ def test_not_admin_no_self_no_staff_team(self): match.maps.add(self.map) data = serializer.to_representation(match) self.assertEqual( - dict(data), + json.loads(json.dumps(data)), { "id": match.pk, "status": str(match.status), From 5dddc4debb0525175f9d8f4e9dde8943a31a20ad Mon Sep 17 00:00:00 2001 From: Lowell Torola Date: Sun, 30 Nov 2025 16:59:09 -0500 Subject: [PATCH 4/8] dict equal --- backend/siarnaq/api/compete/test_views.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/siarnaq/api/compete/test_views.py b/backend/siarnaq/api/compete/test_views.py index b9f5a5f30..2700f90ac 100644 --- a/backend/siarnaq/api/compete/test_views.py +++ b/backend/siarnaq/api/compete/test_views.py @@ -481,7 +481,7 @@ def test_not_admin_has_self_has_staff_team(self): ) match.maps.add(self.map) data = serializer.to_representation(match) - self.assertEqual( + self.assertDictEqual( json.loads(json.dumps(data)), { "id": match.pk, @@ -712,7 +712,7 @@ def test_not_admin_has_self_no_staff_team_tournament_none(self): ) match.maps.add(self.map) data = serializer.to_representation(match) - self.assertEqual( + self.assertDictEqual( json.loads(json.dumps(data)), { "id": match.pk, @@ -783,7 +783,7 @@ def test_not_admin_no_self_has_staff_team(self): ) match.maps.add(self.map) data = serializer.to_representation(match) - self.assertEqual( + self.assertDictEqual( json.loads(json.dumps(data)), { "id": match.pk, @@ -854,7 +854,7 @@ def test_not_admin_no_self_no_staff_team(self): ) match.maps.add(self.map) data = serializer.to_representation(match) - self.assertEqual( + self.assertDictEqual( json.loads(json.dumps(data)), { "id": match.pk, @@ -1242,9 +1242,9 @@ def setUp(self): ) def test_create_autoaccept(self, enqueue): self.client.force_authenticate(self.users[0]) - self.teams[ - 1 - ].profile.auto_accept_reject_ranked = ScrimmageRequestAcceptReject.AUTO_ACCEPT + self.teams[1].profile.auto_accept_reject_ranked = ( + ScrimmageRequestAcceptReject.AUTO_ACCEPT + ) self.teams[1].profile.save() response = self.client.post( reverse("request-list", kwargs={"episode_id": "e1"}), From 8ebcbb9c20438d04c0f5105c3e984f121163c5b9 Mon Sep 17 00:00:00 2001 From: Lowell Torola Date: Tue, 2 Dec 2025 16:38:22 -0500 Subject: [PATCH 5/8] add ordered dict to dict utility as a hack --- backend/siarnaq/api/compete/test_views.py | 26 ++++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/backend/siarnaq/api/compete/test_views.py b/backend/siarnaq/api/compete/test_views.py index 2700f90ac..04523d7fa 100644 --- a/backend/siarnaq/api/compete/test_views.py +++ b/backend/siarnaq/api/compete/test_views.py @@ -1,6 +1,6 @@ import io -import json import random +from collections import OrderedDict from datetime import timedelta from unittest.mock import mock_open, patch @@ -38,6 +38,16 @@ from siarnaq.api.user.models import User +def ordered_dict_to_dict(data) -> dict: + """ + Recursively convert OrderedDict to dict for test comparisons. + """ + if isinstance(data, OrderedDict): + return {key: ordered_dict_to_dict(value) for key, value in data.items()} + else: + return data + + class SubmissionViewSetTestCase(APITestCase): """Test suite for the Submissions API.""" @@ -482,7 +492,7 @@ def test_not_admin_has_self_has_staff_team(self): match.maps.add(self.map) data = serializer.to_representation(match) self.assertDictEqual( - json.loads(json.dumps(data)), + ordered_dict_to_dict(data), { "id": match.pk, "status": str(match.status), @@ -713,7 +723,7 @@ def test_not_admin_has_self_no_staff_team_tournament_none(self): match.maps.add(self.map) data = serializer.to_representation(match) self.assertDictEqual( - json.loads(json.dumps(data)), + ordered_dict_to_dict(data), { "id": match.pk, "status": str(match.status), @@ -784,7 +794,7 @@ def test_not_admin_no_self_has_staff_team(self): match.maps.add(self.map) data = serializer.to_representation(match) self.assertDictEqual( - json.loads(json.dumps(data)), + ordered_dict_to_dict(data), { "id": match.pk, "status": str(match.status), @@ -855,7 +865,7 @@ def test_not_admin_no_self_no_staff_team(self): match.maps.add(self.map) data = serializer.to_representation(match) self.assertDictEqual( - json.loads(json.dumps(data)), + ordered_dict_to_dict(data), { "id": match.pk, "status": str(match.status), @@ -1242,9 +1252,9 @@ def setUp(self): ) def test_create_autoaccept(self, enqueue): self.client.force_authenticate(self.users[0]) - self.teams[1].profile.auto_accept_reject_ranked = ( - ScrimmageRequestAcceptReject.AUTO_ACCEPT - ) + self.teams[ + 1 + ].profile.auto_accept_reject_ranked = ScrimmageRequestAcceptReject.AUTO_ACCEPT self.teams[1].profile.save() response = self.client.post( reverse("request-list", kwargs={"episode_id": "e1"}), From d8e9ff1d7b843ac4514914ac13649b4ec3ac901f Mon Sep 17 00:00:00 2001 From: Lowell Torola Date: Tue, 2 Dec 2025 16:49:49 -0500 Subject: [PATCH 6/8] better recursive utility --- backend/siarnaq/api/compete/test_views.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/siarnaq/api/compete/test_views.py b/backend/siarnaq/api/compete/test_views.py index 04523d7fa..7faeadd0b 100644 --- a/backend/siarnaq/api/compete/test_views.py +++ b/backend/siarnaq/api/compete/test_views.py @@ -38,12 +38,17 @@ from siarnaq.api.user.models import User -def ordered_dict_to_dict(data) -> dict: +def ordered_dict_to_dict(data): """ Recursively convert OrderedDict to dict for test comparisons. + Applies the conversion to ordered dicts, dicts, and lists. """ if isinstance(data, OrderedDict): return {key: ordered_dict_to_dict(value) for key, value in data.items()} + elif isinstance(data, dict): + return {key: ordered_dict_to_dict(value) for key, value in data.items()} + elif isinstance(data, list): + return [ordered_dict_to_dict(item) for item in data] else: return data From e9aa46fc8fa6e104389db269a622c4b4b2583240 Mon Sep 17 00:00:00 2001 From: Lowell Torola Date: Tue, 2 Dec 2025 17:13:22 -0500 Subject: [PATCH 7/8] I have no idea what is going on --- backend/siarnaq/api/compete/test_views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/siarnaq/api/compete/test_views.py b/backend/siarnaq/api/compete/test_views.py index 7faeadd0b..729620789 100644 --- a/backend/siarnaq/api/compete/test_views.py +++ b/backend/siarnaq/api/compete/test_views.py @@ -2,6 +2,7 @@ import random from collections import OrderedDict from datetime import timedelta +from typing import Any from unittest.mock import mock_open, patch from django.test import TestCase, override_settings @@ -38,7 +39,7 @@ from siarnaq.api.user.models import User -def ordered_dict_to_dict(data): +def ordered_dict_to_dict(data: Any) -> Any: """ Recursively convert OrderedDict to dict for test comparisons. Applies the conversion to ordered dicts, dicts, and lists. From 8959577a653b7a02158aeaf7c5bfd81413b85820 Mon Sep 17 00:00:00 2001 From: Lowell Torola Date: Tue, 2 Dec 2025 19:51:51 -0500 Subject: [PATCH 8/8] clean date strings to UTC --- backend/siarnaq/api/compete/test_views.py | 26 ++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/backend/siarnaq/api/compete/test_views.py b/backend/siarnaq/api/compete/test_views.py index 729620789..345850d1e 100644 --- a/backend/siarnaq/api/compete/test_views.py +++ b/backend/siarnaq/api/compete/test_views.py @@ -1,7 +1,9 @@ import io import random +import re from collections import OrderedDict -from datetime import timedelta +from datetime import datetime, timedelta +from datetime import timezone as dt_timezone from typing import Any from unittest.mock import mock_open, patch @@ -41,8 +43,10 @@ def ordered_dict_to_dict(data: Any) -> Any: """ - Recursively convert OrderedDict to dict for test comparisons. - Applies the conversion to ordered dicts, dicts, and lists. + Recursively clean data for test comparisons. + This function: + - Converts OrderedDict to dict recursively + - Normalizes ISO 8601 datetime strings to UTC (Z suffix) """ if isinstance(data, OrderedDict): return {key: ordered_dict_to_dict(value) for key, value in data.items()} @@ -50,6 +54,22 @@ def ordered_dict_to_dict(data: Any) -> Any: return {key: ordered_dict_to_dict(value) for key, value in data.items()} elif isinstance(data, list): return [ordered_dict_to_dict(item) for item in data] + elif isinstance(data, str): + # Check if it's an ISO 8601 datetime string and normalize to UTC + iso_pattern = ( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?([+-]\d{2}:\d{2}|Z)$" + ) + if re.match(iso_pattern, data): + try: + # Parse the datetime and convert to UTC with Z suffix + dt = datetime.fromisoformat(data.replace("Z", "+00:00")) + # Convert to UTC and format with Z suffix + utc_dt = dt.astimezone(dt_timezone.utc) + return utc_dt.isoformat().replace("+00:00", "Z") + except (ValueError, AttributeError): + # If parsing fails, return as-is + return data + return data else: return data