Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
8baf224
46864 feat(forms): add path-based public form routing
EBirkenfeld May 26, 2026
125e67a
46864 feat(forms): add path-based public form routing
EBirkenfeld May 26, 2026
4ab1839
Merge branch 'frontend/configuration/46864__public_forms_via_url_path…
EBirkenfeld May 26, 2026
5bd0421
Merge branch 'frontend/configuration/46864__public_forms_via_url_path…
EBirkenfeld May 26, 2026
2eadc67
Merge branch 'frontend/configuration/46864__public_forms_via_url_path…
EBirkenfeld May 26, 2026
96b42dd
Merge branch 'frontend/configuration/46864__public_forms_via_url_path…
EBirkenfeld May 26, 2026
0d5a052
Merge branch 'frontend/configuration/46864__public_forms_via_url_path…
EBirkenfeld May 26, 2026
9662566
46864 refactor(forms): extract shared isFormPath helper and unify for…
EBirkenfeld May 28, 2026
dc7f0d3
46864 refactor(forms): extract shared isFormPath helper and unify for…
EBirkenfeld May 28, 2026
f16158b
Merge branch 'frontend/configuration/46864__public_forms_via_url_path…
EBirkenfeld May 28, 2026
8aeee60
Merge branch 'frontend/configuration/46864__public_forms_via_url_path…
EBirkenfeld May 28, 2026
8190fe3
Merge branch 'frontend/configuration/46864__public_forms_via_url_path…
EBirkenfeld May 28, 2026
479e453
Merge remote-tracking branch 'origin/master' into frontend/configurat…
EBirkenfeld May 31, 2026
cbc1c95
Merge branch 'master' into frontend/configuration/46864__public_forms…
EBirkenfeld Jun 1, 2026
c405b0b
Merge branch 'master' into frontend/configuration/46864__public_forms…
EBirkenfeld Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions frontend/src/public/forms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { Redirect, Route, Router, Switch } from 'react-router-dom';
import { IntlProvider } from 'react-intl';
import { Provider } from 'react-redux';

import { createBrowserHistory } from 'history';
import { store } from './redux/store';
import { history } from './utils/history';

import { initSentry } from './utils/initSentry';
import { getPublicFormConfig } from './utils/getConfig';

import { getFormsBasename } from './utils/identifyAppPart/constants';
import { EPublicFormRoutes } from './constants/routes';
import { SharedPublicForm, EmbeddedPublicForm } from './components/PublicFormsApp';
import { AppLocale } from './lang';
Expand All @@ -25,13 +26,17 @@ initSentry(getPublicFormConfig, 'forms');
const {
config: { mainPage },
} = getPublicFormConfig();

const formsHistory = createBrowserHistory({
Comment thread
EBirkenfeld marked this conversation as resolved.
basename: getFormsBasename(window.location.pathname) || '/',
});
const currentAppLocale = AppLocale[defaultLocale];

ReactDOM.render(
<Provider store={store}>
<React.Suspense fallback={<div className="loading" />}>

<Router history={history}>
<Router history={formsHistory}>
<IntlProvider locale={currentAppLocale.locale} messages={currentAppLocale.messages}>
<Switch>
<Route
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { getFormsBasename, FORMS_PATH_PREFIX } from '../constants';

describe('getFormsBasename', () => {
describe('path-based mode — returns FORMS_PATH_PREFIX', () => {
it('should return "/forms" for /forms/{token}', () => {
expect(getFormsBasename('/forms/abc123')).toBe(FORMS_PATH_PREFIX);
});

it('should return "/forms" for /forms/{token}/ with trailing slash', () => {
expect(getFormsBasename('/forms/abc123/')).toBe(FORMS_PATH_PREFIX);
});

it('should return "/forms" for exact /forms prefix (no token)', () => {
expect(getFormsBasename('/forms')).toBe(FORMS_PATH_PREFIX);
});

it('should return "/forms" for /forms/ with trailing slash only', () => {
expect(getFormsBasename('/forms/')).toBe(FORMS_PATH_PREFIX);
});

it('should return "/forms" for nested path /forms/embed/{token}', () => {
expect(getFormsBasename('/forms/embed/abc123')).toBe(FORMS_PATH_PREFIX);
});

it('should return "/forms" for deep nested path /forms/a/b/c', () => {
expect(getFormsBasename('/forms/a/b/c')).toBe(FORMS_PATH_PREFIX);
});

it('should return "/forms" for UUID token /forms/550e8400-e29b-41d4-a716-446655440000', () => {
expect(getFormsBasename('/forms/550e8400-e29b-41d4-a716-446655440000')).toBe(FORMS_PATH_PREFIX);
});
});

describe('subdomain mode — returns undefined', () => {
it('should return undefined for root path /', () => {
expect(getFormsBasename('/')).toBeUndefined();
});

it('should return undefined for /{token} (subdomain mode)', () => {
expect(getFormsBasename('/abc123')).toBeUndefined();
});

it('should return undefined for /{token}/ with trailing slash', () => {
expect(getFormsBasename('/abc123/')).toBeUndefined();
});

it('should return undefined for /embed/{token} (subdomain embed)', () => {
expect(getFormsBasename('/embed/abc123')).toBeUndefined();
});

it('should return undefined for /error/ path', () => {
expect(getFormsBasename('/error/')).toBeUndefined();
});
});

describe('boundary cases — paths starting with "form" but not "/forms"', () => {
it('should return undefined for /formsome (no slash after "form")', () => {
expect(getFormsBasename('/formsome')).toBeUndefined();
});

it('should return undefined for /formation/abc', () => {
expect(getFormsBasename('/formation/abc')).toBeUndefined();
});

it('should return undefined for /form (shorter than /forms)', () => {
expect(getFormsBasename('/form')).toBeUndefined();
});

it('should return undefined for /formset/abc', () => {
expect(getFormsBasename('/formset/abc')).toBeUndefined();
});

it('should return undefined for empty path', () => {
expect(getFormsBasename('')).toBeUndefined();
});
});

describe('security — prevents path traversal or injection', () => {
it('should return undefined for /Forms/abc (case-sensitive)', () => {
expect(getFormsBasename('/Forms/abc')).toBeUndefined();
});

it('should return undefined for /FORMS/abc (uppercase)', () => {
expect(getFormsBasename('/FORMS/abc')).toBeUndefined();
});

it('should return "/forms" for /forms/../../etc (still starts with /forms/)', () => {
// The function only checks prefix, path traversal is handled by Express
expect(getFormsBasename('/forms/../../etc')).toBe(FORMS_PATH_PREFIX);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// <reference types="jest" />
import { identifyAppPartOnClient } from '../identifyAppPartOnClient';
import { EAppPart } from '../types';
import { FORMS_PATH_PREFIX } from '../constants';

jest.mock('../../getConfig', () => ({
getBrowserConfig: jest.fn(),
}));

jest.mock('../../history', () => ({
history: { location: { pathname: '/' }, push: jest.fn(), listen: jest.fn() },
}));

import { getBrowserConfig } from '../../getConfig';
import { history } from '../../history';

const setWindowLocation = (overrides: Record<string, string> = {}) => {
Object.defineProperty(window, 'location', {
value: {
pathname: '/',
hostname: 'localhost',
...overrides,
},
writable: true,
configurable: true,
});
};

describe('identifyAppPartOnClient', () => {
const mockGetBrowserConfig = getBrowserConfig as jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
setWindowLocation();
(history as any).location.pathname = '/';
});

describe('forms detection', () => {
it('returns PublicFormApp for path-based forms (/forms/*)', () => {
mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } });
setWindowLocation({ pathname: `${FORMS_PATH_PREFIX}/abc123` });

expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp);
});

it('returns PublicFormApp when pathname equals /forms', () => {
mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } });
setWindowLocation({ pathname: FORMS_PATH_PREFIX });

expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp);
});

it('returns PublicFormApp for subdomain forms', () => {
mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: 'form.example.com' } });
setWindowLocation({ hostname: 'form.example.com' });

expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp);
});

it('returns PublicFormApp when both path and subdomain match', () => {
mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: 'form.example.com' } });
setWindowLocation({
pathname: `${FORMS_PATH_PREFIX}/token`,
hostname: 'form.example.com',
});

expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp);
});
});

describe('guest task', () => {
beforeEach(() => {
mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } });
});

it('returns GuestTaskApp when history pathname contains /guest-task/', () => {
(history as any).location.pathname = '/guest-task/some-token';

expect(identifyAppPartOnClient()).toBe(EAppPart.GuestTaskApp);
});
});

describe('main app fallback', () => {
beforeEach(() => {
mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } });
});

it('returns MainApp for regular paths', () => {
setWindowLocation({ pathname: '/dashboard' });
(history as any).location.pathname = '/dashboard';

expect(identifyAppPartOnClient()).toBe(EAppPart.MainApp);
});

it('returns MainApp when formSubdomain is empty string', () => {
mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } });
setWindowLocation({ hostname: 'localhost', pathname: '/dashboard' });
(history as any).location.pathname = '/dashboard';

expect(identifyAppPartOnClient()).toBe(EAppPart.MainApp);
});
});

describe('edge cases', () => {
beforeEach(() => {
mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } });
});

it('does NOT match /formsome as forms path (boundary check)', () => {
setWindowLocation({ pathname: '/formsome/token' });

expect(identifyAppPartOnClient()).toBe(EAppPart.MainApp);
});

it('does NOT match /form as forms path', () => {
setWindowLocation({ pathname: '/form/token' });

expect(identifyAppPartOnClient()).toBe(EAppPart.MainApp);
});

it('handles formSubdomain=undefined without crashing', () => {
mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: undefined } });
setWindowLocation({ pathname: '/dashboard' });
(history as any).location.pathname = '/dashboard';

expect(identifyAppPartOnClient()).toBe(EAppPart.MainApp);
});

it('partial hostname match — exact comparison rejects substrings', () => {
mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: 'form.example.com' } });
setWindowLocation({ hostname: 'reform.example.com' });

expect(identifyAppPartOnClient()).toBe(EAppPart.MainApp);
});
Comment thread
cursor[bot] marked this conversation as resolved.
});
});
Loading