Skip to content

Commit

Permalink
Merge pull request #3574 from open-formulieren/issue/3362-hash-redirects
Browse files Browse the repository at this point in the history
🐛 Handle hash based frontend routing
  • Loading branch information
sergei-maertens authored Nov 17, 2023
2 parents 141529b + 2dbe608 commit 7b8f512
Show file tree
Hide file tree
Showing 13 changed files with 375 additions and 95 deletions.
6 changes: 6 additions & 0 deletions docs/developers/backend/core/testing-tools.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ HTML assertions
.. automodule:: openforms.utils.tests.webtest_base
:members:

Frontend redirects
==================

.. automodule:: openforms.frontend.tests
:members:

Migrations
==========

Expand Down
18 changes: 17 additions & 1 deletion docs/developers/sdk/embedding.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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/<form-id>/*`` 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
Expand Down
92 changes: 52 additions & 40 deletions src/openforms/appointments/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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]
Expand Down
26 changes: 14 additions & 12 deletions src/openforms/appointments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
Expand Down Expand Up @@ -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),
},
)
3 changes: 3 additions & 0 deletions src/openforms/frontend/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .frontend import get_frontend_redirect_url

__all__ = ["get_frontend_redirect_url"]
28 changes: 28 additions & 0 deletions src/openforms/frontend/frontend.py
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions src/openforms/frontend/tests.py
Original file line number Diff line number Diff line change
@@ -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,
)
34 changes: 34 additions & 0 deletions src/openforms/submissions/migrations/0003_cleanup_urls.py
Original file line number Diff line number Diff line change
@@ -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)
]
4 changes: 3 additions & 1 deletion src/openforms/submissions/models/submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading

0 comments on commit 7b8f512

Please sign in to comment.