From 83fd89dc7c0d3faf1e8da49e131e0bb97c489265 Mon Sep 17 00:00:00 2001 From: svader0 Date: Mon, 17 Aug 2026 12:00:00 -0500 Subject: [PATCH] Check the general questionnaire expiration on the response route Enforcement lived only in the delete sweep on the questionnaire list view. The response route now applies the same comparison, so an expired questionnaire is not answerable before that sweep runs. --- dojo/survey/ui/views.py | 3 + .../test_general_questionnaire_expiry.py | 75 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 unittests/test_general_questionnaire_expiry.py diff --git a/dojo/survey/ui/views.py b/dojo/survey/ui/views.py index 40ec641d850..7d6df57812a 100644 --- a/dojo/survey/ui/views.py +++ b/dojo/survey/ui/views.py @@ -733,6 +733,9 @@ def delete_general_questionnaire(request, esid): def answer_empty_survey(request, esid): general_survey = get_object_or_404(General_Survey, id=esid) + # Same comparison as the sweep in questionnaire(), so the two cannot disagree. + if general_survey.expiration < tz.now(): + raise Http404 engagement_survey = get_object_or_404(Engagement_Survey, id=general_survey.survey_id) engagement, survey = None, None settings = System_Settings.objects.all()[0] diff --git a/unittests/test_general_questionnaire_expiry.py b/unittests/test_general_questionnaire_expiry.py new file mode 100644 index 00000000000..1e45ea68755 --- /dev/null +++ b/unittests/test_general_questionnaire_expiry.py @@ -0,0 +1,75 @@ +""" +Regression tests for the expiration of a shared general questionnaire link +(``answer_empty_survey`` in ``dojo/survey/ui/views.py``). + +``General_Survey.expiration`` is the lifetime of the shared link. The route the +link points at is login exempt, so with "Allow Anonymous Survey Responses" +enabled an unauthenticated client reaches it. The route never read the field, +and the only code acting on it was a delete sweep in the permission gated +questionnaire list view, so an expired link kept accepting responses until an +authorized user happened to load that page. + +The route answers 404 rather than 403 so an expired link and an unknown one +look the same to an anonymous client. +""" +from datetime import timedelta + +from django.urls import reverse +from django.utils import timezone + +from dojo.models import ( + Answered_Survey, + Engagement_Survey, + General_Survey, + TextQuestion, +) +from unittests.dojo_test_case import DojoTestCase + + +class GeneralQuestionnaireExpiryTests(DojoTestCase): + def setUp(self): + super().setUp() + self.system_settings(allow_anonymous_survey_repsonse=True) + self.questionnaire = Engagement_Survey.objects.create( + name="expiry_probe", description="expiry_probe", active=True, + ) + self.question = TextQuestion.objects.create(text="expiry_probe question", order=1) + self.questionnaire.questions.add(self.question) + self.expired = General_Survey.objects.create( + survey=self.questionnaire, + expiration=timezone.now() - timedelta(days=1), + ) + self.live = General_Survey.objects.create( + survey=self.questionnaire, + expiration=timezone.now() + timedelta(days=7), + ) + + def answer_url(self, general_survey): + return reverse("answer_empty_survey", args=(general_survey.id,)) + + def test_expired_link_is_not_readable(self): + self.assertEqual(self.client.get(self.answer_url(self.expired)).status_code, 404) + + def test_expired_link_records_no_response(self): + response = self.client.post( + self.answer_url(self.expired), + {f"{self.question.id}-answer": "answer to an expired questionnaire"}, + ) + + self.assertEqual(response.status_code, 404) + self.assertFalse(Answered_Survey.objects.filter(survey=self.questionnaire).exists()) + self.expired.refresh_from_db() + self.assertEqual(self.expired.num_responses, 0) + + def test_live_link_still_answerable(self): + self.assertEqual(self.client.get(self.answer_url(self.live)).status_code, 200) + + response = self.client.post( + self.answer_url(self.live), + {f"{self.question.id}-answer": "answer to a live questionnaire"}, + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(Answered_Survey.objects.filter(survey=self.questionnaire).count(), 1) + self.live.refresh_from_db() + self.assertEqual(self.live.num_responses, 1)