feat: enhance exam answer submission security and add tests for user authentication

This commit is contained in:
Ross
2026-07-05 21:59:02 +01:00
parent bb6b4c625c
commit 2744758aa8
4 changed files with 212 additions and 35 deletions
+2
View File
@@ -14,6 +14,8 @@ log.txt
temp/
media_test/*
# Loki runtime data
docker/loki-data/
docker/loki-wal/
+172
View File
@@ -0,0 +1,172 @@
import json
import pytest
from django.urls import reverse
from django.contrib.auth.models import User
from django.test import Client
from django.utils import timezone
from anatomy.models import Exam as AnatomyExam, AnatomyQuestion, QuestionType, Structure
from generic.models import CidUser, CidUserGroup
@pytest.fixture
def logged_in_user(db):
user = User.objects.create_user(username="user1", password="password", email="user1@test.com")
return user
@pytest.fixture
def logged_in_client(logged_in_user):
client = Client()
client.force_login(logged_in_user)
return client
@pytest.fixture
def anonymous_client(db):
return Client()
@pytest.fixture
def exam_data(db):
exam = AnatomyExam.objects.create(
name="Anatomy Exam Test",
exam_mode=True,
active=True,
open_access=False,
exam_open_access=True, # allows open access so we don't have to populate cid/user lists for candidate test
)
qtype = QuestionType.objects.create(id=1, text="Test Type")
structure = Structure.objects.create(name="Test Structure")
question = AnatomyQuestion.objects.create(
question_type=qtype,
structure=structure,
image="test.jpg",
description="Test Question description",
)
question.exams.set([exam])
return exam, question
@pytest.mark.django_db
def test_authenticated_user_submit_success(logged_in_client, logged_in_user, exam_data):
exam, question = exam_data
payload = {
"eid": f"anatomy/{exam.pk}",
"cid": f"u-{logged_in_user.pk}",
"start_time": timezone.now().timestamp(),
"answers": json.dumps([
{
"eid": f"anatomy/{exam.pk}",
"cid": f"u-{logged_in_user.pk}",
"qid": question.pk,
"ans": "Normal",
"qidn": "1",
}
])
}
response = logged_in_client.post(reverse("global_exam_answers_submit"), payload)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["question_count"] == 1
@pytest.mark.django_db
def test_authenticated_user_submit_mismatch_other_user(logged_in_client, logged_in_user, exam_data):
exam, question = exam_data
payload = {
"eid": f"anatomy/{exam.pk}",
"cid": f"u-{logged_in_user.pk + 999}", # root cid doesn't match authenticated user
"start_time": timezone.now().timestamp(),
"answers": json.dumps([
{
"eid": f"anatomy/{exam.pk}",
"cid": f"u-{logged_in_user.pk}",
"qid": question.pk,
"ans": "Normal",
"qidn": "1",
}
])
}
response = logged_in_client.post(reverse("global_exam_answers_submit"), payload)
assert response.status_code == 200
data = response.json()
assert data["success"] is False
assert "user / cid mismatch" in data["error"]
@pytest.mark.django_db
def test_authenticated_user_submit_mismatch_answer_level(logged_in_client, logged_in_user, exam_data):
exam, question = exam_data
payload = {
"eid": f"anatomy/{exam.pk}",
"cid": f"u-{logged_in_user.pk}",
"start_time": timezone.now().timestamp(),
"answers": json.dumps([
{
"eid": f"anatomy/{exam.pk}",
"cid": f"u-{logged_in_user.pk + 999}", # answer level cid doesn't match authenticated user
"qid": question.pk,
"ans": "Normal",
"qidn": "1",
}
])
}
response = logged_in_client.post(reverse("global_exam_answers_submit"), payload)
assert response.status_code == 200
data = response.json()
assert data["success"] is False
assert "user / uid mismatch" in data["error"]
@pytest.mark.django_db
def test_anonymous_user_submit_user_prefix_fails(anonymous_client, exam_data):
exam, question = exam_data
payload = {
"eid": f"anatomy/{exam.pk}",
"cid": "u-1", # prefix u- is forbidden for anonymous users
"start_time": timezone.now().timestamp(),
"answers": json.dumps([
{
"eid": f"anatomy/{exam.pk}",
"cid": "u-1",
"qid": question.pk,
"ans": "Normal",
"qidn": "1",
}
])
}
response = anonymous_client.post(reverse("global_exam_answers_submit"), payload)
assert response.status_code == 200
data = response.json()
assert data["success"] is False
assert "authentication required" in data["error"]
@pytest.mark.django_db
def test_candidate_submit_success(anonymous_client, exam_data):
exam, question = exam_data
cid_user = CidUser.objects.create(
cid=8888,
name="Candidate 1",
email="candidate1@test.com",
passcode="candidatepass",
)
# Associate CID user with the exam
exam.exam_open_access = False
group = CidUserGroup.objects.create(name="Test Group")
group.ciduser_set.add(cid_user)
exam.cid_user_groups.add(group)
exam.valid_cid_users.add(cid_user)
payload = {
"eid": f"anatomy/{exam.pk}",
"cid": "8888",
"start_time": timezone.now().timestamp(),
"answers": json.dumps([
{
"eid": f"anatomy/{exam.pk}",
"cid": "8888",
"passcode": "candidatepass",
"qid": question.pk,
"ans": "Normal",
"qidn": "1",
}
])
}
response = anonymous_client.post(reverse("global_exam_answers_submit"), payload)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["question_count"] == 1
+34 -18
View File
@@ -3294,8 +3294,21 @@ class ExamViews(View, LoginRequiredMixin):
@method_decorator(csrf_exempt)
def post_exam_answers(self, request):
if request.method == "POST":
n = 0
root_cid = request.POST.get("cid")
if not root_cid:
return JsonResponse({"success": False, "error": "cid not defined"})
if request.user.is_authenticated:
expected_cid = f"u-{request.user.id}"
if root_cid != expected_cid:
return JsonResponse({"success": False, "error": "user / cid mismatch"})
else:
if root_cid.startswith("u-"):
return JsonResponse(
{"success": False, "error": "authentication required for user submissions"}
)
n = 0
for answer in json.loads(request.POST.get("answers")):
logger.debug("Processing answer: {}", answer)
@@ -3303,25 +3316,28 @@ class ExamViews(View, LoginRequiredMixin):
uid = False
passcode = None
try:
cid = int(answer["cid"])
passcode = answer["passcode"]
except ValueError:
if answer["cid"].startswith("u-"):
cid = False
uid = int(answer["cid"][2:])
ans_cid = answer.get("cid")
if not ans_cid:
return JsonResponse({"success": False, "error": "answer cid not defined"})
if not uid == request.user.id:
return JsonResponse(
{"success": False, "error": "user / uid mismatch"}
)
else:
if request.user.is_authenticated:
if ans_cid != f"u-{request.user.id}":
return JsonResponse({"success": False, "error": "user / uid mismatch"})
uid = request.user.id
cid = False
passcode = None
else:
if str(ans_cid).startswith("u-"):
return JsonResponse(
{
"success": False,
"error": "cid or eid or answers not defined",
}
{"success": False, "error": "authentication required"}
)
try:
cid = int(ans_cid)
passcode = answer["passcode"]
uid = False
except (ValueError, KeyError):
return JsonResponse(
{"success": False, "error": "invalid cid or passcode"}
)
if not uid:
+4 -17
View File
@@ -1,21 +1,8 @@
RTS: we should show a refresh button for each of the exam/packet sections (as well as a global refresh that refreshes all)
When editing / viewing a case in the atlas it is currently possible to associate resources that have already been created. it should also be possible to create and attach the resource inline.
We need to update RTS have consistent logging that can be enabled and disabled via both the gui and url parameters. All current log statements need to be set with an appropriate level.
When editing a case as part of a casecollection we should redirect back to the case as part of the casecollection when it is saved / cancelled.
RTS currently supports user login in addition to the cid / passcode however this is not particuarly well implemented and is not secure (if someone knows a users user id they can submit results for that user). this needs to be fixed with a more robust implementation.
We need to optimise the case series update view. This currently hangs on the production site (likely due to the large number of series).
In RTS we need to tidy up the resuming of exams that have already been started.
Running the docker local-up script does not always shut down the currently running containsers:
(rad) ⋊> ~/p/rad on master ./scripts/local-up.sh -n 23:07:18
Starting compose with COMPOSE_ENV=dev (using .env.dev)
Ensuring loki and logs folders exist under /home/ross/penracourses/rad/docker and /home/ross/penracourses/rad/logs
Attempting to chown loki folders to UID 10001 (may prompt for sudo password)
[sudo] password for ross:
[+] Running 5/5
✔ Container docker-nginx-1 Stopped 0.2s
✔ Container docker-worker-1 Stopped 0.5s
✔ Container docker-web-1 Stopped 10.3s
✔ Container docker-redis-1 Stopped 0.2s
✔ Network docker_default Removed 0.0s
Error response from daemon: error while removing network: network docker_default has active endpoints (name:"docker-cache-1" id:"4650361118ce", name:"docker-db-1" id:"2a82b22517d0")
In RTS we need to improve the fallback that is shown if we fail to manually submit an exam via the finish button.