Skip to content

Commit

Permalink
🐛 [open-formulieren/open-forms#3362] Handle server side redirects
Browse files Browse the repository at this point in the history
  • Loading branch information
Viicos committed Nov 15, 2023
1 parent 0890c27 commit c87f004
Show file tree
Hide file tree
Showing 3 changed files with 170 additions and 11 deletions.
24 changes: 24 additions & 0 deletions src/components/routingActions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Get the correct redirect path for an action.
* @param {string} action The action to be performed.
* @param {URLSearchParams} actionParams The params linked to the action.
* @returns {{path: string, query?: URLSearchParams}} An object containing the pathname to be used,
* alongside with optional query parameters.
*/
export const getRedirectParams = (action, actionParams) => {
switch (action) {
case 'cosign':
return {
path: 'cosign',
query: new URLSearchParams({submission_uuid: actionParams.get('submission_uuid')}),
};
case 'afspraak-annuleren':
return {path: 'afspraak-annuleren'};
case 'afspraak-maken':
return {path: 'afspraak-maken'};
case 'resume':
return {path: `stap/${actionParams.get('next_step')}`};
default:
return {};

Check warning on line 22 in src/components/routingActions.js

View check run for this annotation

Codecov / codecov/patch

src/components/routingActions.js#L22

Added line #L22 was not covered by tests
}
};
58 changes: 51 additions & 7 deletions src/sdk.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {NonceProvider} from 'react-select';
import {ConfigContext, FormContext} from 'Context';
import {get} from 'api';
import App, {routes as nestedRoutes} from 'components/App';
import {getRedirectParams} from 'components/routingActions';
import {AddFetchAuth} from 'formio/plugins';
import {CSPNonce} from 'headers';
import {I18NErrorBoundary, I18NManager} from 'i18n';
Expand Down Expand Up @@ -95,24 +96,67 @@ class OpenForm {
CSPNonce.setValue(CSPNonceValue);
initialiseSentry(sentryDSN, sentryEnv);

// ensure that the basename has no trailing slash (for react router)
let pathname = basePath || window.location.pathname;
let pathname = this.useHashRouting
? window.location.pathname
: basePath || window.location.pathname;

if (pathname.endsWith('/')) {
// ensure that the basename has no trailing slash (for react router)
pathname = pathname.slice(0, pathname.length - 1);
}
this.basePath = pathname;
this.makeRedirect();
this.calculateClientBaseUrl();
}

makeRedirect() {
// Perform pre-redirect based on this action: this is decoupled from the backend
const query = new URLSearchParams(document.location.search);
const action = query.get('_of_action');
if (action) {
// as actionParams is in the form `q1%3Dquery1%26q2%3Dquery2`, we first URL decode and then parse it:
const actionParams = new URLSearchParams(decodeURIComponent(query.get('_of_action_params')));
query.delete('_of_action');
query.delete('_of_action_params');

const {path: redirectPath, query: redirectQuery = new URLSearchParams()} = getRedirectParams(
action,
actionParams
);

const newUrl = new URL(this.basePath, window.location.origin);
if (!this.useHashRouting) {
newUrl.pathname += `/${redirectPath}`;
// We first append query params from the redirect action
for (let [key, val] of redirectQuery.entries()) {
newUrl.searchParams.append(key, val);
}
// And extra unrelated query params
for (let [key, val] of query.entries()) {
newUrl.searchParams.append(key, val);

Check warning on line 136 in src/sdk.js

View check run for this annotation

Codecov / codecov/patch

src/sdk.js#L136

Added line #L136 was not covered by tests
}
} else {
// First add extra unrelated query params, before hash (`#`)
for (let [key, val] of query.entries()) {
newUrl.searchParams.append(key, val);
}

// Then add our custom path as the hash part. Our query parameters are added here,
// but are only parsed as such by react-router, e.g. location.searchParams will not include them (as per RFC).
// This is why unrelated query params were added before hash.
// TODO use query.size once we have better browser support
newUrl.hash = `/${redirectPath}${[...redirectQuery].length ? '?' + redirectQuery : ''}`;
}

window.history.replaceState(null, '', newUrl);
}
}

calculateClientBaseUrl() {
// calculate the client-side base URL, as this is recorded in backend calls for
// submissions.
const clientBase = resolvePath(this.basePath).pathname; // has leading slash
const prefix = this.useHashRouting ? window.location.pathname : ''; // may have trailing slash
this.clientBaseUrl = new URL(
this.useHashRouting ? `${prefix}#${clientBase}` : clientBase,
window.location.origin
).href;
this.clientBaseUrl = new URL(clientBase, window.location.origin).href;
}

async init() {
Expand Down
99 changes: 95 additions & 4 deletions src/sdk.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,17 +125,108 @@ describe('OpenForm', () => {
expect(form.clientBaseUrl).toEqual('http://localhost/some-subpath');
});

it('should correctly set the formUrl (hash fragment routing)', () => {
it("shouldn't take basepath into account (hash based routing)", () => {
mswServer.use(...apiMocks);
window.history.pushState({}, 'Dummy title', '/some-server-side/path');
window.history.pushState({}, 'Dummy title', '/some-path');
const formRoot = document.createElement('div');
const form = new OpenForm(formRoot, {
baseUrl: BASE_URL,
basePath: '/some-subpath/',
basePath: '/i-must-be-ignored',
formId: '81a22589-abce-4147-a2a3-62e9a56685aa',
useHashRouting: true,
});

expect(form.clientBaseUrl).toEqual('http://localhost/some-server-side/path#/some-subpath');
expect(form.clientBaseUrl).toEqual('http://localhost/some-path');
});

it.each([
[
'/some-subpath?_of_action=afspraak-annuleren',
'http://localhost/some-subpath/afspraak-annuleren',
],
[
'/some-subpath?_of_action=afspraak-maken',
'http://localhost/some-subpath/afspraak-maken/producten', // SDK redirects to producten
],
[
'/some-subpath?_of_action=cosign&_of_action_params=submission_uuid%3Dabc',
'http://localhost/some-subpath/cosign?submission_uuid=abc',
],
[
'/some-subpath?_of_action=resume&_of_action_params=next_step%3Dstep-1',
'http://localhost/some-subpath/startpagina', // SDK redirects to start page
],
])('should handle action redirects correctly', async (initialUrl, expected) => {
mswServer.use(...apiMocks);
const formRoot = document.createElement('div');
window.history.pushState(null, '', initialUrl);
const form = new OpenForm(formRoot, {
baseUrl: BASE_URL,
basePath: '/some-subpath',
formId: '81a22589-abce-4147-a2a3-62e9a56685aa',
useHashRouting: false,
lang: 'nl',
});
await act(async () => await form.init());

// wait for the loader to be removed when all network requests have completed
await waitForElementToBeRemoved(() => within(formRoot).getByRole('status'));
expect(location.href).toEqual(expected);
});

it.each([
// With a base path:
[
'/base-path/?_of_action=afspraak-annuleren&unrelated_q=1',
'http://localhost/base-path?unrelated_q=1#/afspraak-annuleren',
],
[
'/base-path/?_of_action=afspraak-maken&unrelated_q=1',
'http://localhost/base-path?unrelated_q=1#/afspraak-maken',
],
[
'/base-path/?_of_action=cosign&_of_action_params=submission_uuid%3Dabc&unrelated_q=1',
'http://localhost/base-path?unrelated_q=1#/cosign?submission_uuid=abc',
],
[
'/base-path/?_of_action=resume&_of_action_params=next_step%3Dstep-1&unrelated_q=1',
'http://localhost/base-path?unrelated_q=1#/stap/step-1',
],
// Without a base path:
[
'/?_of_action=afspraak-annuleren&unrelated_q=1',
'http://localhost/?unrelated_q=1#/afspraak-annuleren',
],
[
'/?_of_action=afspraak-maken&unrelated_q=1',
'http://localhost/?unrelated_q=1#/afspraak-maken/producten', // SDK redirects to producten
],
[
'/?_of_action=cosign&_of_action_params=submission_uuid%3Dabc&unrelated_q=1',
'http://localhost/?unrelated_q=1#/cosign?submission_uuid=abc',
],
[
'/?_of_action=resume&_of_action_params=next_step%3Dstep-1&unrelated_q=1',
'http://localhost/?unrelated_q=1#/startpagina', // SDK redirects to start page
],
])(
'should handle action redirects correctly (hash based routing)',
async (initialUrl, expected) => {
mswServer.use(...apiMocks);
const formRoot = document.createElement('div');
window.history.pushState(null, '', initialUrl);
const form = new OpenForm(formRoot, {
baseUrl: BASE_URL,
basePath: '/i-must-be-ignored',
formId: '81a22589-abce-4147-a2a3-62e9a56685aa',
useHashRouting: true,
lang: 'nl',
});
await act(async () => await form.init());

// wait for the loader to be removed when all network requests have completed
await waitForElementToBeRemoved(() => within(formRoot).getByRole('status'));
expect(location.href).toEqual(expected);
}
);
});

0 comments on commit c87f004

Please sign in to comment.