feat: enhance exam answer submission security and add tests for user authentication
This commit is contained in:
@@ -14,6 +14,8 @@ log.txt
|
||||
|
||||
temp/
|
||||
|
||||
media_test/*
|
||||
|
||||
# Loki runtime data
|
||||
docker/loki-data/
|
||||
docker/loki-wal/
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user