diff --git a/docs/developers/backend/core/testing-tools.rst b/docs/developers/backend/core/testing-tools.rst index 84f98f6ee5..58e785f1ca 100644 --- a/docs/developers/backend/core/testing-tools.rst +++ b/docs/developers/backend/core/testing-tools.rst @@ -13,6 +13,12 @@ HTML assertions .. automodule:: openforms.utils.tests.webtest_base :members: +Frontend redirects +================== + +.. automodule:: openforms.frontend.tests + :members: + Migrations ========== diff --git a/docs/developers/sdk/embedding.rst b/docs/developers/sdk/embedding.rst index 86c912d153..664f454b09 100644 --- a/docs/developers/sdk/embedding.rst +++ b/docs/developers/sdk/embedding.rst @@ -57,7 +57,23 @@ Available options ``basePath``: Optional, but highly recommended. The SDK considers this as the base URL and builds all - URLs relatively to this URL. If not provided, ``window.location.pathname`` is used. + URLs relatively to this URL. + + If not provided, ``window.location.pathname`` is used. + + .. note:: + ``basePath`` only applies when using the default browser routing. If hash based routing + is used (see ``useHashRouting`` below), the option will be silently ignored. + +``useHashRouting``: + Whether hash based routing should be used. Defaults to ``false``. This option is useful when embedding + Open Forms with a CMS. If the SDK is hosted at ``https://example.com/basepath?cms_query=1``, the resulting URL + would be ``https://example.com/basepath?cms_query=1#/startpagina`` (SDK specific query parameters would come + at the end of the URL). + + .. warning:: + This is a last resort solution - preferably the backend where you embed the form would set up "wildcard" routes to + ensure that refreshing the page works, e.g. ``/some-path//*`` should just load the CMS page for a specific form. ``CSPNonce``: Recommended. The page's CSP Nonce value if inline styles are blocked by your diff --git a/src/openforms/appointments/tests/test_views.py b/src/openforms/appointments/tests/test_views.py index 3024c3272f..5c497d38c3 100644 --- a/src/openforms/appointments/tests/test_views.py +++ b/src/openforms/appointments/tests/test_views.py @@ -11,6 +11,7 @@ from openforms.authentication.constants import FORM_AUTH_SESSION_KEY, AuthAttribute from openforms.authentication.contrib.digid.constants import DIGID_DEFAULT_LOA from openforms.forms.tests.factories import FormFactory +from openforms.frontend.tests import FrontendRedirectMixin from openforms.logging.models import TimelineLogProxy from openforms.payments.constants import PaymentStatus from openforms.payments.tests.factories import SubmissionPaymentFactory @@ -26,7 +27,7 @@ @freeze_time("2021-07-15T21:15:00Z") -class VerifyCancelAppointmentLinkViewTests(TestCase): +class VerifyCancelAppointmentLinkViewTests(FrontendRedirectMixin, TestCase): def test_good_token_and_submission_redirect_and_add_submission_to_session(self): submission = SubmissionFactory.create( completed=True, form_url="http://maykinmedia.nl/myform" @@ -49,18 +50,15 @@ def test_good_token_and_submission_redirect_and_add_submission_to_session(self): with freeze_time("2021-07-16T21:15:00Z"): response = self.client.get(endpoint) - expected_redirect_url = ( - furl("http://maykinmedia.nl/myform/afspraak-annuleren") - .add( - { - "time": "2021-07-21T12:00:00+00:00", - "submission_uuid": str(submission.uuid), - } - ) - .url - ) - self.assertRedirects( - response, expected_redirect_url, fetch_redirect_response=False + self.assertRedirectsToFrontend( + response, + frontend_base_url="http://maykinmedia.nl/myform", + action="afspraak-annuleren", + action_params={ + "time": "2021-07-21T12:00:00+00:00", + "submission_uuid": str(submission.uuid), + }, + fetch_redirect_response=False, ) # Assert submission is stored in session self.assertIn( @@ -197,14 +195,6 @@ def test_after_successful_auth_redirects_to_form(self): "submission_uuid": submission.uuid, }, ) - expected_redirect_url = furl(submission.form_url) - expected_redirect_url /= "afspraak-annuleren" - expected_redirect_url.add( - { - "time": start_time.isoformat(), - "submission_uuid": str(submission.uuid), - } - ) # Add form_auth to session, as the authentication plugin would do it session = self.client.session @@ -218,9 +208,17 @@ def test_after_successful_auth_redirects_to_form(self): response = self.client.get(endpoint) - self.assertRedirects( - response, expected_redirect_url.url, fetch_redirect_response=False + self.assertRedirectsToFrontend( + response, + frontend_base_url=submission.form_url, + action="afspraak-annuleren", + action_params={ + "time": start_time.isoformat(), + "submission_uuid": str(submission.uuid), + }, + fetch_redirect_response=False, ) + self.assertIn(SUBMISSIONS_SESSION_KEY, self.client.session) self.assertIn( str(submission.uuid), self.client.session[SUBMISSIONS_SESSION_KEY] @@ -343,7 +341,7 @@ def test_invalid_auth_value_raises_exception(self): @freeze_time("2021-07-15T21:15:00Z") -class VerifyChangeAppointmentLinkViewTests(TestCase): +class VerifyChangeAppointmentLinkViewTests(FrontendRedirectMixin, TestCase): def test_good_token_and_submission_redirect_and_add_submission_to_session(self): submission = SubmissionFactory.from_components( completed=True, @@ -397,14 +395,18 @@ def test_good_token_and_submission_redirect_and_add_submission_to_session(self): new_submission = Submission.objects.exclude(id=submission.id).get() # after initiating change, we expect the bsn to be stored in plain text (again) self.assertEqual(new_submission.auth_info.value, "000000000") - expected_redirect_url = ( - f"http://maykinmedia.nl/myform/stap/{form_definition.slug}" - f"?submission_uuid={new_submission.uuid}" - ) - self.assertRedirects( - response, expected_redirect_url, fetch_redirect_response=False + self.assertRedirectsToFrontend( + response, + frontend_base_url=submission.form_url, + action="resume", + action_params={ + "step_slug": form_definition.slug, + "submission_uuid": str(new_submission.uuid), + }, + fetch_redirect_response=False, ) + # Assert new submission was created self.assertEqual(Submission.objects.count(), 2) # Assert old submission not stored in session @@ -580,9 +582,16 @@ def test_redirect_to_first_step_when_appointment_form_definition_can_not_be_foun response = self.client.get(endpoint) new_submission = Submission.objects.exclude(id=submission.id).get() - expected_redirect_url = f"http://maykinmedia.nl/myform/stap/step-1?submission_uuid={new_submission.uuid}" - self.assertRedirects( - response, expected_redirect_url, fetch_redirect_response=False + + self.assertRedirectsToFrontend( + response, + frontend_base_url=submission.form_url, + action="resume", + action_params={ + "step_slug": "step-1", + "submission_uuid": str(new_submission.uuid), + }, + fetch_redirect_response=False, ) def test_redirects_to_auth_if_form_requires_login(self): @@ -658,14 +667,17 @@ def test_after_successful_auth_redirects_to_form(self): self.assertIsNotNone(new_submission) - expected_redirect_url = furl(submission.form_url) - expected_redirect_url /= "stap" - expected_redirect_url /= "test-step" - expected_redirect_url.args["submission_uuid"] = str(new_submission.uuid) - - self.assertRedirects( - response, expected_redirect_url.url, fetch_redirect_response=False + self.assertRedirectsToFrontend( + response, + frontend_base_url=submission.form_url, + action="resume", + action_params={ + "step_slug": "test-step", + "submission_uuid": str(new_submission.uuid), + }, + fetch_redirect_response=False, ) + self.assertIn(SUBMISSIONS_SESSION_KEY, self.client.session) self.assertIn( str(new_submission.uuid), self.client.session[SUBMISSIONS_SESSION_KEY] diff --git a/src/openforms/appointments/views.py b/src/openforms/appointments/views.py index 7c3ecd960b..5361d73711 100644 --- a/src/openforms/appointments/views.py +++ b/src/openforms/appointments/views.py @@ -3,6 +3,7 @@ from django.views.generic import RedirectView from openforms.authentication.service import FORM_AUTH_SESSION_KEY, store_auth_details +from openforms.frontend import get_frontend_redirect_url from openforms.submissions.models import Submission from openforms.submissions.views import ResumeFormMixin @@ -16,15 +17,14 @@ class VerifyCancelAppointmentLinkView(ResumeFormMixin, RedirectView): token_generator = submission_appointment_token_generator def get_form_resume_url(self, submission: Submission) -> str: - f = submission.cleaned_form_url - f /= "afspraak-annuleren" - f.add( - { + return get_frontend_redirect_url( + submission, + action="afspraak-annuleren", + action_params={ "time": submission.appointment_info.start_time.isoformat(), "submission_uuid": str(submission.uuid), - } + }, ) - return f.url class VerifyChangeAppointmentLinkView(ResumeFormMixin, RedirectView): @@ -58,9 +58,11 @@ def get_form_resume_url(self, submission: Submission) -> str: assert next_step is not None, "Form has no steps to redirect to!" - f = submission.cleaned_form_url - f /= "stap" - f /= next_step.slug - f.add({"submission_uuid": submission.uuid}) - - return f.url + return get_frontend_redirect_url( + submission, + action="resume", + action_params={ + "step_slug": next_step.slug, + "submission_uuid": str(submission.uuid), + }, + ) diff --git a/src/openforms/frontend/__init__.py b/src/openforms/frontend/__init__.py new file mode 100644 index 0000000000..2c624b19a0 --- /dev/null +++ b/src/openforms/frontend/__init__.py @@ -0,0 +1,3 @@ +from .frontend import get_frontend_redirect_url + +__all__ = ["get_frontend_redirect_url"] diff --git a/src/openforms/frontend/frontend.py b/src/openforms/frontend/frontend.py new file mode 100644 index 0000000000..b09d56d7fa --- /dev/null +++ b/src/openforms/frontend/frontend.py @@ -0,0 +1,28 @@ +import json +from typing import Literal, TypeAlias + +from openforms.submissions.models import Submission + +SDKAction: TypeAlias = Literal["resume", "afspraak-annuleren", "cosign"] + + +def get_frontend_redirect_url( + submission: Submission, + action: SDKAction, + action_params: dict[str, str] | None = None, +) -> str: + """Get the frontend redirect URL depending on the action. + + Some actions require arguments to be specified. The frontend will take care of building the right redirection + based on the action and action arguments. + """ + f = submission.cleaned_form_url + f.query.remove("_of_action") + f.query.remove("_of_action_params") + _query = { + "_of_action": action, + } + if action_params: + _query["_of_action_params"] = json.dumps(action_params) + + return f.add(_query).url diff --git a/src/openforms/frontend/tests.py b/src/openforms/frontend/tests.py new file mode 100644 index 0000000000..82d61cda9d --- /dev/null +++ b/src/openforms/frontend/tests.py @@ -0,0 +1,48 @@ +import json +from typing import Any + +from django.http.response import HttpResponse +from django.test import SimpleTestCase + +from furl import furl + +from .frontend import SDKAction + + +class FrontendRedirectMixin: + """A mixin providing a helper method checking frontend redirects.""" + + def assertRedirectsToFrontend( + self: SimpleTestCase, + response: HttpResponse, + frontend_base_url: str, + action: SDKAction, + action_params: dict[str, str] | None = None, + **kwargs: Any + ) -> None: + """Assert that a response redirected to a specific frontend URL. + + :param response: The response to test the redirection on. + :param frontend_base_url: The base URL of the frontend. + :param action: The SDK action performed. + :param action_params: Optional parameters for the action. + :param `**kwargs`: Additional kwargs to be passed to :meth:`django.test.SimpleTestCase.assertRedirects`. + """ + + expected_redirect_url = furl(frontend_base_url) + expected_redirect_url.remove("_of_action") + expected_redirect_url.remove("_of_action_params") + expected_redirect_url.add( + { + "_of_action": action, + } + ) + + if action_params: + expected_redirect_url.add({"_of_action_params": json.dumps(action_params)}) + + return self.assertRedirects( + response, + expected_redirect_url.url, + **kwargs, + ) diff --git a/src/openforms/submissions/migrations/0003_cleanup_urls.py b/src/openforms/submissions/migrations/0003_cleanup_urls.py new file mode 100644 index 0000000000..e3b63d7235 --- /dev/null +++ b/src/openforms/submissions/migrations/0003_cleanup_urls.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.21 on 2023-11-17 10:40 + +from django.db import migrations +from django.db.migrations.state import StateApps +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +from furl import furl + + +def cleanup_submission_urls( + apps: StateApps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + + Submission = apps.get_model("submissions", "Submission") + + for submission in Submission.objects.iterator(): + f = furl(submission.form_url) + f.remove(fragment=True) + if f.path.segments[-1:] == ["startpagina"]: + f.path.segments.remove("startpagina") + submission.form_url = f.url + + submission.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("submissions", "0002_change_json_encoder"), + ] + + operations = [ + migrations.RunPython(cleanup_submission_urls, migrations.RunPython.noop) + ] diff --git a/src/openforms/submissions/models/submission.py b/src/openforms/submissions/models/submission.py index f0360e64a4..ca85117248 100644 --- a/src/openforms/submissions/models/submission.py +++ b/src/openforms/submissions/models/submission.py @@ -349,7 +349,9 @@ def cleaned_form_url(self) -> furl: # if the url path ends with 'startpagina', strip it off if furl_instance.path.segments[-1:] == ["startpagina"]: furl_instance.path.segments.remove("startpagina") - return furl_instance + return furl_instance.remove( + fragment=True + ) # Fragments are present in hash based routing @property def total_configuration_wrapper(self) -> FormioConfigurationWrapper: diff --git a/src/openforms/submissions/tests/test_resume_form_view.py b/src/openforms/submissions/tests/test_resume_form_view.py index 508df90613..60f93ddb90 100644 --- a/src/openforms/submissions/tests/test_resume_form_view.py +++ b/src/openforms/submissions/tests/test_resume_form_view.py @@ -10,13 +10,14 @@ from openforms.authentication.constants import FORM_AUTH_SESSION_KEY, AuthAttribute from openforms.authentication.contrib.digid.constants import DIGID_DEFAULT_LOA from openforms.config.models import GlobalConfiguration +from openforms.frontend.tests import FrontendRedirectMixin from ..constants import SUBMISSIONS_SESSION_KEY from ..tokens import submission_resume_token_generator from .factories import SubmissionFactory, SubmissionStepFactory -class SubmissionResumeViewTests(TestCase): +class SubmissionResumeViewTests(FrontendRedirectMixin, TestCase): def test_good_token_and_submission_redirect_and_add_submission_to_session(self): submission = SubmissionFactory.from_components( completed=True, @@ -49,17 +50,17 @@ def test_good_token_and_submission_redirect_and_add_submission_to_session(self): response = self.client.get(endpoint) - f = furl(submission.form_url) - # furl adds paths with the /= operator - f /= "stap" - f /= submission.get_last_completed_step().form_step.slug - # Add the submission uuid to the query param - f.add({"submission_uuid": submission.uuid}) - - expected_redirect_url = f.url - self.assertRedirects( - response, expected_redirect_url, fetch_redirect_response=False + self.assertRedirectsToFrontend( + response, + frontend_base_url=submission.form_url, + action="resume", + action_params={ + "step_slug": submission.get_last_completed_step().form_step.slug, + "submission_uuid": str(submission.uuid), + }, + fetch_redirect_response=False, ) + # Assert submission is stored in session self.assertIn( str(submission.uuid), self.client.session[SUBMISSIONS_SESSION_KEY] @@ -209,10 +210,6 @@ def test_after_successful_auth_redirects_to_form(self): "submission_uuid": submission.uuid, }, ) - expected_redirect_url = furl(submission.form_url) - expected_redirect_url /= "stap" - expected_redirect_url /= form_step.slug - expected_redirect_url.args["submission_uuid"] = submission.uuid # Add form_auth to session, as the authentication plugin would do it session = self.client.session @@ -226,9 +223,17 @@ def test_after_successful_auth_redirects_to_form(self): response = self.client.get(endpoint) - self.assertRedirects( - response, expected_redirect_url.url, fetch_redirect_response=False + self.assertRedirectsToFrontend( + response, + frontend_base_url=submission.form_url, + action="resume", + action_params={ + "step_slug": form_step.slug, + "submission_uuid": str(submission.uuid), + }, + fetch_redirect_response=False, ) + self.assertIn(SUBMISSIONS_SESSION_KEY, self.client.session) self.assertIn( str(submission.uuid), self.client.session[SUBMISSIONS_SESSION_KEY] @@ -405,14 +410,13 @@ def test_resume_creates_valid_url(self): response = self.client.get(endpoint) - f = furl("http://maykinmedia.nl/some-form/") - # furl adds paths with the /= operator - f /= "stap" - f /= submission.get_last_completed_step().form_step.slug - # Add the submission uuid to the query param - f.add({"submission_uuid": submission.uuid}) - - expected_redirect_url = f.url - self.assertRedirects( - response, expected_redirect_url, fetch_redirect_response=False + self.assertRedirectsToFrontend( + response, + frontend_base_url="http://maykinmedia.nl/some-form", + action="resume", + action_params={ + "step_slug": submission.get_last_completed_step().form_step.slug, + "submission_uuid": str(submission.uuid), + }, + fetch_redirect_response=False, ) diff --git a/src/openforms/submissions/tests/test_views.py b/src/openforms/submissions/tests/test_views.py index 25440b957c..bcde6e6e67 100644 --- a/src/openforms/submissions/tests/test_views.py +++ b/src/openforms/submissions/tests/test_views.py @@ -6,10 +6,11 @@ from openforms.authentication.constants import FORM_AUTH_SESSION_KEY from openforms.authentication.contrib.digid.constants import DIGID_DEFAULT_LOA +from openforms.frontend.tests import FrontendRedirectMixin from openforms.submissions.tests.factories import SubmissionFactory -class SearchSubmissionForCosignView(WebTest): +class SearchSubmissionForCosignView(FrontendRedirectMixin, WebTest): def setUp(self) -> None: super().setUp() @@ -54,9 +55,13 @@ def test_successfully_submit_form(self): form["code"] = submission.public_registration_reference submission_response = form.submit() - self.assertRedirects( + self.assertRedirectsToFrontend( submission_response, - f"http://url-to-form.nl/cosign/check?submission_uuid={submission.uuid}", + frontend_base_url="http://url-to-form.nl/", + action="cosign", + action_params={ + "submission_uuid": str(submission.uuid), + }, fetch_redirect_response=False, ) diff --git a/src/openforms/submissions/views.py b/src/openforms/submissions/views.py index 36f4a61a6a..632ce24ec3 100644 --- a/src/openforms/submissions/views.py +++ b/src/openforms/submissions/views.py @@ -21,6 +21,7 @@ meets_plugin_requirements, ) from openforms.forms.models import Form +from openforms.frontend import get_frontend_redirect_url from openforms.tokens import BaseTokenGenerator from .constants import RegistrationStatuses @@ -175,18 +176,18 @@ class ResumeSubmissionView(ResumeFormMixin, RedirectView): token_generator = submission_resume_token_generator def get_form_resume_url(self, submission: Submission) -> str: - form_resume_url = submission.cleaned_form_url - state = submission.load_execution_state() last_completed_step = state.get_last_completed_step() target_step = last_completed_step or state.submission_steps[0] - # furl adds paths with the /= operator - form_resume_url /= "stap" - form_resume_url /= target_step.form_step.slug - # Add the submission uuid to the query param - form_resume_url.add({"submission_uuid": submission.uuid}) - return form_resume_url.url + return get_frontend_redirect_url( + submission, + action="resume", + action_params={ + "step_slug": target_step.form_step.slug, + "submission_uuid": str(submission.uuid), + }, + ) class SubmissionAttachmentDownloadView(LoginRequiredMixin, PrivateMediaView): @@ -279,6 +280,10 @@ def form_valid(self, form): return super().form_valid(form) def get_success_url(self): - cosign_page = self.submission.cleaned_form_url / "cosign" / "check" - cosign_page.args["submission_uuid"] = self.submission.uuid - return cosign_page.url + return get_frontend_redirect_url( + self.submission, + action="cosign", + action_params={ + "submission_uuid": str(self.submission.uuid), + }, + ) diff --git a/src/openforms/tests/test_frontend_utils.py b/src/openforms/tests/test_frontend_utils.py new file mode 100644 index 0000000000..4de7b3561e --- /dev/null +++ b/src/openforms/tests/test_frontend_utils.py @@ -0,0 +1,115 @@ +import json + +from django.test import TestCase + +from furl import furl + +from openforms.frontend import get_frontend_redirect_url +from openforms.submissions.tests.factories import SubmissionFactory + + +class FrontendRedirectTests(TestCase): + def test_frontend_redirect_no_hash(self): + submission = SubmissionFactory.create( + form_url="https://example.com/basepath", + ) + + action_params = {"action_1": "arg_1", "action_2": "arg_2"} + + redirect_url = get_frontend_redirect_url( + submission, + "resume", + action_params, + ) + + excpected_redirect_url = furl("https://example.com/basepath") + excpected_redirect_url.add( + { + "_of_action": "resume", + "_of_action_params": json.dumps(action_params), + } + ) + + self.assertURLEqual( + redirect_url, + excpected_redirect_url.url, + ) + + def test_frontend_redirect_hash(self): + submission = SubmissionFactory.create( + form_url="https://example.com/basepath#after_hash", + ) + + action_params = {"action_1": "arg_1", "action_2": "arg_2"} + + redirect_url = get_frontend_redirect_url( + submission, + "resume", + action_params, + ) + + excpected_redirect_url = furl("https://example.com/basepath") + excpected_redirect_url.add( + { + "_of_action": "resume", + "_of_action_params": json.dumps(action_params), + } + ) + + self.assertURLEqual( + redirect_url, + excpected_redirect_url.url, + ) + + def test_frontend_redirect_special_chars(self): + submission = SubmissionFactory.create( + form_url="https://example.com/basepath", + ) + + action_params = {"action&=": " arg_1 +"} + + redirect_url = get_frontend_redirect_url( + submission, + "resume", + action_params, + ) + + excpected_redirect_url = furl("https://example.com/basepath") + excpected_redirect_url.add( + { + "_of_action": "resume", + "_of_action_params": json.dumps(action_params), + } + ) + + self.assertURLEqual( + redirect_url, + excpected_redirect_url.url, + ) + + def test_frontend_redirect_query_param(self): + submission = SubmissionFactory.create( + form_url="https://example.com/basepath?unrelated_query=1&_of_action=unrelated", + ) + + action_params = {"action": "arg"} + + redirect_url = get_frontend_redirect_url( + submission, + "resume", + action_params, + ) + + excpected_redirect_url = furl("https://example.com/basepath") + excpected_redirect_url.add( + { + "_of_action": "resume", + "_of_action_params": json.dumps(action_params), + "unrelated_query": "1", + } + ) + + self.assertURLEqual( + redirect_url, + excpected_redirect_url.url, + )