Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6b45aa9
changed minio and mc location yet again
Sep 25, 2026
dab50a6
Merge pull request #2568 from codalab/change_minio_image_location_yet…
ObadaS Sep 25, 2026
54f2eb8
Fix fact sheets migration
Didayolo Sep 25, 2026
6ffd3f4
Merge pull request #2571 from codalab/fact_sheets_migration
ObadaS Sep 25, 2026
91c6541
attempt to fix E2E multitask test
IdirLISN Sep 21, 2026
6dadb57
adapting test file to new conf
IdirLISN Sep 21, 2026
6bb50df
Merge pull request #2556 from codalab/fix/multitask_test
ObadaS Sep 28, 2026
772ab02
Add security policy
ihsaan-ullah Sep 28, 2026
e720bd7
Merge pull request #2580 from codalab/add-security-policy
ObadaS Sep 29, 2026
2f6b82c
Update external competitions list styling
ihsaan-ullah Sep 28, 2026
16bcb79
scroll to top on next or previous page load with a short delay
ihsaan-ullah Sep 29, 2026
704d93c
style updates for buttons to match the bluish theme, moved all css to…
ihsaan-ullah Sep 29, 2026
106c0d4
Merge pull request #2574 from codalab/external_competitions_update
ObadaS Sep 29, 2026
fc271d7
Hide queue connection details in competition details
ihsaan-ullah Sep 28, 2026
ab76362
Merge pull request #2581 from codalab/queue_in_competition_detail_api
ObadaS Sep 29, 2026
d677277
Update version.json
ObadaS Sep 29, 2026
b6d4157
Merge pull request #2582 from codalab/Version-Bump
ObadaS Sep 29, 2026
71a371e
Merge pull request #2570 from codalab/better_account_delete_message
ObadaS Sep 29, 2026
c08299b
Order competitions newest first in CompetitionViewSet. With this chan…
ihsaan-ullah Sep 28, 2026
bf061e0
Merge pull request #2575 from codalab/benchmark_management_new_first
Didayolo Sep 29, 2026
f481a17
Some competition fields are now hidden from non-admins (participants …
ihsaan-ullah Sep 29, 2026
b4f9d17
code cleaned
ihsaan-ullah Sep 29, 2026
f64c302
Comp Detail API Tests added. QueueTests merged into CompetitionDetail…
ihsaan-ullah Sep 29, 2026
49dcad1
Merge pull request #2584 from codalab/hide_unwanted_comp_fields_from_…
ObadaS Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,15 @@ jobs:

- run:
name: "Tests: Run unit/integration tests (excluding e2e)"
command: docker compose exec django py.test src/
command: docker compose exec -e CELERY_TASK_ALWAYS_EAGER=True django py.test src/

# We give the name of the test files manually because we need test_auth.py to be run before the others for state.json file to be created
# CI="true" to skip some tests that fail in the CI for now
- run:
name: "Tests: Run end-to-end (E2E) tests"
command: |
docker compose exec django python ./manage.py createsuperuser --no-input
cd tests && CI=True $HOME/.local/bin/uv run pytest test_auth.py test_account_creation.py test_competition.py test_submission.py
cd tests && $HOME/.local/bin/uv run pytest test_auth.py test_account_creation.py test_competition.py test_submission.py
no_output_timeout: 30m

# Example to run specific set of tests (for debugging individual tests from a batch of tests)
Expand Down
2 changes: 2 additions & 0 deletions .env_circleci
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,5 @@ DJANGO_SUPERUSER_USERNAME=codabench
DOMAIN_NAME=localhost:80
TLS_EMAIL=your@email.com
SUBMISSIONS_API_URL=http://django:8000/api

CELERY_TASK_ALWAYS_EAGER=False
30 changes: 30 additions & 0 deletions .github/SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Security Policy

## Supported Versions

Security fixes are applied to the `develop` branch and included in the next release.
Only the [latest release](https://github.com/codalab/codabench/releases/latest) is supported.
If you run your own Codabench instance, please keep it up to date.

## Reporting a Vulnerability

**Please do not report security vulnerabilities through public GitHub issues, pull requests, or discussions.**

Report them privately using one of the following channels:

- **GitHub:** go to the [Security tab](https://github.com/codalab/codabench/security) of this repository and click **Report a vulnerability**.
- **Email:** send the details to [info@codabench.org](mailto:info@codabench.org) with `[SECURITY]` in the subject line.

Please include as much of the following as possible:

- Type of issue (e.g. XSS, SQL injection, privilege escalation, sandbox escape in the compute worker)
- Affected component (Django app, API endpoint, frontend page, compute worker, etc.) and file paths if known
- Affected version, commit, or URL
- Step-by-step instructions to reproduce the issue
- Proof-of-concept or exploit code, if available
- Impact of the issue and how an attacker might exploit it

## Handling of Reports

- We will investigate, keep you informed of our progress, and let you know when a fix is released.
- Please give us reasonable time to fix the issue before disclosing it publicly.
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ services:
# Minio local storage helper
#----------------------------------------------------------------------------------------------------
minio:
image: quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z
image: codalab/minio:RELEASE.2025-04-22T22-12-26Z
command: server /export
volumes:
- ./var/minio:/export
Expand All @@ -69,7 +69,7 @@ services:
interval: 5s
retries: 5
createbuckets:
image: quay.io/minio/mc:RELEASE.2025-07-21T05-28-08Z
image: codalab/mc:RELEASE.2025-07-21T05-28-08Z
depends_on:
minio:
condition: service_healthy
Expand Down
30 changes: 25 additions & 5 deletions src/apps/api/serializers/competitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from profiles.models import User
from tasks.models import Task

from api.serializers.queues import QueueSerializer
from api.serializers.queues import QueueSerializer, QueuePublicSerializer
from datetime import datetime
from django.utils.timezone import now

Expand Down Expand Up @@ -375,9 +375,29 @@ class CompetitionDetailSerializer(serializers.ModelSerializer):
participant_status = serializers.CharField(read_only=True)
participants_count = serializers.IntegerField(read_only=True)
submissions_count = serializers.IntegerField(read_only=True)
queue = QueueSerializer(read_only=True)
queue = QueuePublicSerializer(read_only=True)
whitelist_emails = serializers.SerializerMethodField()

# Fields only visible to competition admins (creator, collaborators, staff/superusers)
ADMIN_ONLY_FIELDS = (
'secret_key',
'whitelist_emails',
'collaborators',
'queue',
'enable_detailed_results',
'show_detailed_results_in_submission_panel',
'show_detailed_results_in_leaderboard',
'auto_run_submissions',
'forum',
'forum_enabled',
'enable_human_in_the_loop',
'registration_auto_approve',
'can_participants_make_submissions_public',
'make_programs_available',
'make_input_data_available',
'fact_sheet',
)

class Meta:
model = Competition
fields = (
Expand Down Expand Up @@ -446,10 +466,10 @@ def to_representation(self, instance):
representation = super().to_representation(instance)
user = self.context['request'].user

# If user is not admin/creator/collaborator then do not include secret_key and whitelist_emails
# If user is not admin/creator/collaborator then do not include the admin-only fields
if not instance.user_has_admin_permission(user):
representation.pop('secret_key', None)
representation.pop('whitelist_emails', None)
for field in self.ADMIN_ONLY_FIELDS:
representation.pop(field, None)

return representation

Expand Down
8 changes: 8 additions & 0 deletions src/apps/api/serializers/queues.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ def validate(self, attrs):
return super().validate(attrs)


class QueuePublicSerializer(serializers.ModelSerializer):
"""Minimal queue info safe to expose on public endpoints (no broker credentials)."""
class Meta:
model = Queue
fields = ('id', 'name')
read_only_fields = fields


class QueueSerializer(QueueOwnerMixin, serializers.ModelSerializer):
is_owner = serializers.SerializerMethodField()
owner = serializers.CharField(source='owner.username', read_only=True)
Expand Down
203 changes: 201 additions & 2 deletions src/apps/api/tests/test_competitions.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import json
import random
import csv
import uuid
from zipfile import ZipFile
from io import StringIO, BytesIO
from unittest import mock
from django.urls import reverse
from rest_framework.test import APITestCase

from api.serializers.competitions import CompetitionSerializer
from api.serializers.competitions import CompetitionSerializer, CompetitionDetailSerializer
from competitions.models import CompetitionParticipant, Submission, Competition
from factories import UserFactory, CompetitionFactory, CompetitionParticipantFactory, PhaseFactory, LeaderboardFactory, \
ColumnFactory, SubmissionFactory, SubmissionScoreFactory, TaskFactory
ColumnFactory, SubmissionFactory, SubmissionScoreFactory, TaskFactory, QueueFactory


class CompetitionTests(APITestCase):
Expand Down Expand Up @@ -84,6 +85,204 @@ def test_delete_own_competition(self):
assert not Competition.objects.filter(pk=self.comp.pk).exists()


class CompetitionDetailTests(APITestCase):
"""
Tests for the competition detail API: who can access public and private competitions,
which fields admins and non-admins see, and that the queue only includes its id and name.
"""
def setUp(self):
self.creator = UserFactory(username='creator', password='creator')
self.collaborator = UserFactory(username='collaborator', password='collaborator')
self.participant = UserFactory(username='participant', password='participant')
self.pending_participant = UserFactory(username='pending_participant', password='pending_participant')
self.other_user = UserFactory(username='other_user', password='other_user')
self.superuser = UserFactory(username='superuser', password='superuser', is_superuser=True, is_staff=True)
self.queue_owner = UserFactory(username='queue_owner', password='queue_owner')
# Mock RabbitMQ so saving the queue doesn't create a real vhost; return a fake vhost UUID instead
with mock.patch('queues.models.rabbit.create_queue') as rabbit_create_queue:
rabbit_create_queue.return_value = uuid.uuid4()
self.queue = QueueFactory(owner=self.queue_owner, is_public=True)

self.public_comp = CompetitionFactory(
created_by=self.creator, collaborators=[self.collaborator], queue=self.queue, published=True
)
self.private_comp = CompetitionFactory(
created_by=self.creator, collaborators=[self.collaborator], queue=self.queue, published=False
)
for comp in (self.public_comp, self.private_comp):
CompetitionParticipantFactory(user=self.participant, competition=comp, status='approved')
CompetitionParticipantFactory(user=self.pending_participant, competition=comp, status='pending')
self.queue_owner_comp = CompetitionFactory(created_by=self.queue_owner, queue=self.queue, published=True)

# One visible and one hidden leaderboard on the public competition
self.visible_leaderboard = LeaderboardFactory(hidden=False)
self.hidden_leaderboard = LeaderboardFactory(hidden=True)
PhaseFactory(competition=self.public_comp, leaderboard=self.visible_leaderboard)
PhaseFactory(competition=self.public_comp, leaderboard=self.hidden_leaderboard)

# None means a logged-out user
self.admins = [self.creator, self.collaborator, self.superuser]
self.non_admins = [None, self.participant, self.pending_participant, self.other_user]

def _get(self, competition, user=None, **params):
"""Request the detail API of `competition` as `user`, or logged out when `user` is None."""
self.client.logout()
if user:
self.client.force_login(user)
url = reverse('competition-detail', kwargs={"pk": competition.pk})
return self.client.get(url, params)

# ---------- Access ----------

def test_anyone_can_access_public_competition(self):
"""
Admins, participants, other users and logged-out users request a published competition.
Expects a 200 response for all of them.
"""
for user in self.admins + self.non_admins:
assert self._get(self.public_comp, user).status_code == 200

def test_organizers_approved_participants_and_superusers_can_access_private_competition(self):
"""
The creator, a collaborator, an approved participant and a superuser request an unpublished competition.
Expects a 200 response for all of them.
"""
for user in [self.creator, self.collaborator, self.participant, self.superuser]:
assert self._get(self.private_comp, user).status_code == 200

def test_other_users_cannot_access_private_competition(self):
"""
A logged-out user, an unrelated user and a pending participant request an unpublished competition
without a secret key.
Expects a 404 response for all of them.
"""
for user in [None, self.other_user, self.pending_participant]:
assert self._get(self.private_comp, user).status_code == 404

def test_valid_secret_key_gives_access_to_private_competition(self):
"""
A logged-out user, an unrelated user and a pending participant request an unpublished competition
with its secret key.
Expects a 200 response for all of them.
"""
for user in [None, self.other_user, self.pending_participant]:
resp = self._get(self.private_comp, user, secret_key=str(self.private_comp.secret_key))
assert resp.status_code == 200

def test_invalid_secret_key_does_not_give_access_to_private_competition(self):
"""
A logged-out user and an unrelated user request an unpublished competition with a wrong secret key.
Expects a 404 response for both.
"""
for user in [None, self.other_user]:
resp = self._get(self.private_comp, user, secret_key=str(uuid.uuid4()))
assert resp.status_code == 404

# ---------- Fields ----------

def test_admins_see_admin_only_fields(self):
"""
The creator, a collaborator and a superuser request a published competition.
Expects every admin-only field in the response, including the competition's secret key.
"""
for user in self.admins:
resp = self._get(self.public_comp, user)
assert resp.status_code == 200
for field in CompetitionDetailSerializer.ADMIN_ONLY_FIELDS:
assert field in resp.data, field
assert resp.data['secret_key'] == str(self.public_comp.secret_key)

def test_non_admins_do_not_see_admin_only_fields(self):
"""
Participants, an unrelated user and a logged-out user request a published competition.
Expects the public fields in the response, no admin-only field,
and the competition's secret key absent from the whole response body.
"""
for user in self.non_admins:
resp = self._get(self.public_comp, user)
assert resp.status_code == 200
for field in ('id', 'title', 'created_by', 'phases', 'leaderboards'):
assert field in resp.data, field
for field in CompetitionDetailSerializer.ADMIN_ONLY_FIELDS:
assert field not in resp.data, field
assert str(self.public_comp.secret_key) not in resp.content.decode()

def test_non_admins_with_secret_key_do_not_see_admin_only_fields(self):
"""
A logged-out user and an unrelated user open an unpublished competition with its secret key.
Expects a 200 response without any admin-only field.
"""
for user in [None, self.other_user]:
resp = self._get(self.private_comp, user, secret_key=str(self.private_comp.secret_key))
assert resp.status_code == 200
for field in CompetitionDetailSerializer.ADMIN_ONLY_FIELDS:
assert field not in resp.data, field

def test_admins_see_hidden_leaderboards(self):
"""
The creator, a collaborator and a superuser request a competition with a visible and a hidden leaderboard.
Expects both leaderboards in the response.
"""
for user in self.admins:
resp = self._get(self.public_comp, user)
leaderboard_ids = {lb['id'] for lb in resp.data['leaderboards']}
assert leaderboard_ids == {self.visible_leaderboard.id, self.hidden_leaderboard.id}

def test_non_admins_do_not_see_hidden_leaderboards(self):
"""
Participants, an unrelated user and a logged-out user request a competition
with a visible and a hidden leaderboard.
Expects only the visible leaderboard in the response.
"""
for user in self.non_admins:
resp = self._get(self.public_comp, user)
leaderboard_ids = {lb['id'] for lb in resp.data['leaderboards']}
assert leaderboard_ids == {self.visible_leaderboard.id}

# ---------- Queue ----------

def test_admins_see_only_queue_id_and_name(self):
"""
The creator and a collaborator (neither owns the queue) and a superuser request a competition
that uses someone else's queue.
Expects the queue to have only id and name.
"""
for user in self.admins:
resp = self._get(self.public_comp, user)
assert resp.status_code == 200
assert resp.data['queue'] == {'id': self.queue.id, 'name': self.queue.name}

def test_queue_owner_sees_only_queue_id_and_name_on_own_competition(self):
"""
The queue owner requests the detail API of their own competition that uses their queue.
Expects the queue to have only id and name.
Queue owners can get the broker URL from the queues API instead.
"""
resp = self._get(self.queue_owner_comp, self.queue_owner)
assert resp.status_code == 200
assert resp.data['queue'] == {'id': self.queue.id, 'name': self.queue.name}

def test_non_admins_do_not_see_queue(self):
"""
Participants, an unrelated user and a logged-out user request a competition that uses a queue.
Expects no queue field in the response.
"""
for user in self.non_admins:
resp = self._get(self.public_comp, user)
assert resp.status_code == 200
assert 'queue' not in resp.data

def test_admins_see_null_queue_when_competition_has_no_queue(self):
"""
The creator requests a competition that doesn't use a custom queue.
Expects the queue field to be None.
"""
comp = CompetitionFactory(created_by=self.creator, queue=None, published=True)
resp = self._get(comp, self.creator)
assert resp.status_code == 200
assert resp.data['queue'] is None


class CompetitionListTests(APITestCase):
def setUp(self):
self.user = UserFactory(username='user', password='user')
Expand Down
2 changes: 1 addition & 1 deletion src/apps/api/views/competitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ def get_queryset(self):
if search_query:
qs = qs.filter(Q(title__icontains=search_query) | Q(description__icontains=search_query))

qs = qs.order_by('created_when')
qs = qs.order_by('-created_when')
return qs

def get_permissions(self):
Expand Down
1 change: 1 addition & 0 deletions src/apps/competitions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ def apply_phase_migration(self, current_phase, next_phase, force_migration=False
owner=submission.owner,
data=submission.data,
organization=submission.organization,
fact_sheet_answers=submission.fact_sheet_answers,
)
new_submission.save(ignore_submission_limit=True)
new_submission.start()
Expand Down
8 changes: 8 additions & 0 deletions src/apps/competitions/tests/test_phase_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@ def test_only_parent_submissions_migrated(self):
mock_sub_start = self.mock_migration()
assert mock_sub_start.call_count == 1

def test_fact_sheet_answers_are_migrated(self):
answers = {'method_name': 'my method', 'uses_external_data': 'false'}
self.phase1.submissions.update(fact_sheet_answers=answers)
self.mock_migration()
assert self.phase2.submissions.exists()
for submission in self.phase2.submissions.all():
assert submission.fact_sheet_answers == answers


class PhaseStatusTests(TestCase):
def setUp(self):
Expand Down
Loading
Loading