From 8baf224433831359d0e3a9c4e608d407da72aa09 Mon Sep 17 00:00:00 2001 From: Eugen Birkenfeld Date: Tue, 26 May 2026 07:20:33 +0500 Subject: [PATCH 1/4] 46864 feat(forms): add path-based public form routing --- backend/src/settings.py | 12 +- docker-compose.src.yml | 10 +- docker-compose.yml | 10 +- frontend/src/public/forms.tsx | 9 +- .../__tests__/getFormsBasename.test.ts | 92 +++++++++++ .../__tests__/identifyAppPartOnClient.test.ts | 142 +++++++++++++++++ .../__tests__/identifyAppPartOnServer.test.ts | 143 ++++++++++++++++++ .../public/utils/identifyAppPart/constants.ts | 13 ++ .../identifyAppPartOnClient.ts | 17 ++- .../identifyAppPartOnServer.ts | 15 +- .../handlers/__tests__/mainHandler.test.ts | 6 + .../__tests__/authMiddleware.test.ts | 2 + frontend/src/server/server.ts | 10 +- nginx/templates/nginx.conf.template | 12 ++ 14 files changed, 468 insertions(+), 25 deletions(-) create mode 100644 frontend/src/public/utils/identifyAppPart/__tests__/getFormsBasename.test.ts create mode 100644 frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts create mode 100644 frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts diff --git a/backend/src/settings.py b/backend/src/settings.py index b7704164d..3ac6dc6a9 100644 --- a/backend/src/settings.py +++ b/backend/src/settings.py @@ -11,6 +11,7 @@ import datetime import os from os import environ as env +from urllib.parse import urlparse from configurations import Configuration, values from corsheaders.defaults import default_headers @@ -108,7 +109,16 @@ class Common(Configuration): # A list of origins that are authorized to make cross-site HTTP requests. # A list of origins echoed back to the client in the # Access-Control-Allow-Origin header. Defaults to []. - CORS_ORIGIN_WHITELIST = [FRONTEND_URL, FORMS_URL] + _forms_origin = None + if FORMS_URL: + _parsed = urlparse(FORMS_URL) + if _parsed.netloc: + _forms_origin = ( + f"{_parsed.scheme}://{_parsed.netloc}" + ) + CORS_ORIGIN_WHITELIST = [FRONTEND_URL] + if _forms_origin and _forms_origin != FRONTEND_URL: + CORS_ORIGIN_WHITELIST.append(_forms_origin) EXTRA_CORS_ORIGIN_WHITELIST = env.get('CORS_ORIGIN_WHITELIST') if EXTRA_CORS_ORIGIN_WHITELIST: CORS_ORIGIN_WHITELIST.extend(EXTRA_CORS_ORIGIN_WHITELIST.split(' ')) diff --git a/docker-compose.src.yml b/docker-compose.src.yml index 8a56efeba..da460742a 100755 --- a/docker-compose.src.yml +++ b/docker-compose.src.yml @@ -71,7 +71,7 @@ services: environment: BACKEND_URL: ${BACKEND_URL:-http://localhost:8001} FRONTEND_URL: ${FRONTEND_URL:-http://localhost} - FORMS_URL: ${FORMS_URL:-http://form.localhost} + FORMS_URL: ${FORMS_URL:-http://localhost/forms} ENVIRONMENT: ${ENVIRONMENT:-Production} RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru @@ -174,7 +174,7 @@ services: environment: BACKEND_URL: ${BACKEND_URL:-http://localhost:8001} FRONTEND_URL: ${FRONTEND_URL:-http://localhost} - FORMS_URL: ${FORMS_URL:-http://form.localhost} + FORMS_URL: ${FORMS_URL:-http://localhost/forms} ENVIRONMENT: ${ENVIRONMENT:-Production} RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru @@ -271,7 +271,7 @@ services: environment: BACKEND_URL: ${BACKEND_URL:-http://localhost:8001} FRONTEND_URL: ${FRONTEND_URL:-http://localhost} - FORMS_URL: ${FORMS_URL:-http://form.localhost} + FORMS_URL: ${FORMS_URL:-http://localhost/forms} ENVIRONMENT: ${ENVIRONMENT:-Production} RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru @@ -369,7 +369,7 @@ services: BACKEND_URL: ${BACKEND_URL:-http://localhost:8001} FRONTEND_URL: ${FRONTEND_URL:-http://localhost} WSS_URL: ${WSS_URL:-ws://localhost:8001/} - FORM_DOMAIN: ${FORM_DOMAIN:-form.localhost} + FORM_DOMAIN: ${FORM_DOMAIN:-} MCS_RUN_ENV: ${MCS_RUN_ENV:-prod} NODE_OPTIONS: ${NODE_OPTIONS:---max-old-space-size=3072} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru @@ -413,7 +413,7 @@ services: SSL: ${SSL:-no} BACKEND_DOMAIN: ${BACKEND_DOMAIN:-localhost} FRONTEND_DOMAIN: ${FRONTEND_DOMAIN:-localhost} - FORM_DOMAIN: ${FORM_DOMAIN:-form.localhost} + FORM_DOMAIN: ${FORM_DOMAIN:-_} NGINX_ENVSUBST_OUTPUT_DIR: /etc/nginx/ NGINX_ENVSUBST_TEMPLATE_DIR: /etc/nginx/templates volumes: diff --git a/docker-compose.yml b/docker-compose.yml index ea914dc76..06e27c574 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -72,7 +72,7 @@ services: environment: BACKEND_URL: ${BACKEND_URL:-http://localhost:8001} FRONTEND_URL: ${FRONTEND_URL:-http://localhost} - FORMS_URL: ${FORMS_URL:-http://form.localhost} + FORMS_URL: ${FORMS_URL:-http://localhost/forms} ENVIRONMENT: ${ENVIRONMENT:-Production} RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru @@ -175,7 +175,7 @@ services: environment: BACKEND_URL: ${BACKEND_URL:-http://localhost:8001} FRONTEND_URL: ${FRONTEND_URL:-http://localhost} - FORMS_URL: ${FORMS_URL:-http://form.localhost} + FORMS_URL: ${FORMS_URL:-http://localhost/forms} ENVIRONMENT: ${ENVIRONMENT:-Production} RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru @@ -273,7 +273,7 @@ services: environment: BACKEND_URL: ${BACKEND_URL:-http://localhost:8001} FRONTEND_URL: ${FRONTEND_URL:-http://localhost} - FORMS_URL: ${FORMS_URL:-http://form.localhost} + FORMS_URL: ${FORMS_URL:-http://localhost/forms} ENVIRONMENT: ${ENVIRONMENT:-Production} RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru @@ -372,7 +372,7 @@ services: BACKEND_URL: ${BACKEND_URL:-http://localhost:8001} FRONTEND_URL: ${FRONTEND_URL:-http://localhost} WSS_URL: ${WSS_URL:-ws://localhost:8001} - FORM_DOMAIN: ${FORM_DOMAIN:-form.localhost} + FORM_DOMAIN: ${FORM_DOMAIN:-} MCS_RUN_ENV: ${MCS_RUN_ENV:-prod} NODE_OPTIONS: ${NODE_OPTIONS:---max-old-space-size=3072} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru @@ -415,7 +415,7 @@ services: environment: BACKEND_DOMAIN: ${BACKEND_DOMAIN:-localhost} FRONTEND_DOMAIN: ${FRONTEND_DOMAIN:-localhost} - FORM_DOMAIN: ${FORM_DOMAIN:-form.localhost} + FORM_DOMAIN: ${FORM_DOMAIN:-_} NGINX_ENVSUBST_OUTPUT_DIR: /etc/nginx/ NGINX_ENVSUBST_TEMPLATE_DIR: /etc/nginx/templates volumes: diff --git a/frontend/src/public/forms.tsx b/frontend/src/public/forms.tsx index 7a1a54e8e..348c575f6 100644 --- a/frontend/src/public/forms.tsx +++ b/frontend/src/public/forms.tsx @@ -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'; @@ -25,13 +26,17 @@ initSentry(getPublicFormConfig, 'forms'); const { config: { mainPage }, } = getPublicFormConfig(); + +const formsHistory = createBrowserHistory({ + basename: getFormsBasename(window.location.pathname), +}); const currentAppLocale = AppLocale[defaultLocale]; ReactDOM.render( }> - + { + 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); + }); + }); +}); diff --git a/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts new file mode 100644 index 000000000..f669a97b0 --- /dev/null +++ b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts @@ -0,0 +1,142 @@ +// +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 = {}) => { + 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('path-based forms', () => { + beforeEach(() => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } }); + }); + + it('returns PublicFormApp when pathname starts with FORMS_PATH_PREFIX/', () => { + setWindowLocation({ pathname: `${FORMS_PATH_PREFIX}/abc123` }); + + expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); + }); + + it('returns PublicFormApp when pathname equals FORMS_PATH_PREFIX', () => { + setWindowLocation({ pathname: FORMS_PATH_PREFIX }); + + expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); + }); + }); + + describe('subdomain forms', () => { + it('returns PublicFormApp when hostname matches formSubdomain', () => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: 'form.example.com' } }); + setWindowLocation({ hostname: 'form.example.com' }); + + expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); + }); + + it('returns MainApp when formSubdomain is empty string (no false positive)', () => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } }); + setWindowLocation({ hostname: 'localhost', pathname: '/dashboard' }); + (history as any).location.pathname = '/dashboard'; + + expect(identifyAppPartOnClient()).toBe(EAppPart.MainApp); + }); + }); + + 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); + }); + }); + + describe('priority order', () => { + it('path-based forms takes priority over subdomain', () => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: 'form.example.com' } }); + setWindowLocation({ + pathname: `${FORMS_PATH_PREFIX}/token`, + hostname: 'form.example.com', + }); + + expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); + }); + }); + + 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 — includes() matches substrings', () => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: 'form.example.com' } }); + setWindowLocation({ hostname: 'reform.example.com' }); + + expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); + }); + }); +}); diff --git a/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts new file mode 100644 index 000000000..1dde75bf6 --- /dev/null +++ b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts @@ -0,0 +1,143 @@ +// +import { identifyAppPartOnServer } from '../identifyAppPartOnServer'; +import { EAppPart } from '../types'; +import { FORMS_PATH_PREFIX } from '../constants'; + +jest.mock('../../getConfig', () => ({ + getConfig: jest.fn(), +})); + +import { getConfig } from '../../getConfig'; + +const makeRequest = (overrides: Record = {}) => ({ + baseUrl: '', + path: '/', + hostname: 'localhost', + url: '/', + headers: {}, + ...overrides, +} as any); + +describe('identifyAppPartOnServer', () => { + const mockGetConfig = getConfig as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('path-based forms', () => { + beforeEach(() => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + }); + + it('returns PublicFormApp when baseUrl is FORMS_PATH_PREFIX', () => { + const req = makeRequest({ baseUrl: FORMS_PATH_PREFIX, path: '/abc123' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + + it('returns PublicFormApp when path starts with FORMS_PATH_PREFIX/', () => { + const req = makeRequest({ path: `${FORMS_PATH_PREFIX}/abc123` }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + }); + + describe('subdomain forms', () => { + it('returns PublicFormApp when hostname matches formSubdomain', () => { + mockGetConfig.mockReturnValue({ formSubdomain: 'form.example.com' }); + const req = makeRequest({ hostname: 'form.example.com' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + + it('returns MainApp when formSubdomain is empty string (no false positive)', () => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + const req = makeRequest({ hostname: 'localhost', path: '/dashboard' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); + }); + }); + + describe('guest task', () => { + beforeEach(() => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + }); + + it('returns GuestTaskApp when url contains /guest-task/', () => { + const req = makeRequest({ url: '/guest-task/some-token' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.GuestTaskApp); + }); + }); + + describe('main app fallback', () => { + beforeEach(() => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + }); + + it('returns MainApp for regular paths', () => { + const req = makeRequest({ path: '/dashboard' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); + }); + }); + + describe('priority order', () => { + it('path-based forms takes priority over subdomain', () => { + mockGetConfig.mockReturnValue({ formSubdomain: 'form.example.com' }); + const req = makeRequest({ + baseUrl: FORMS_PATH_PREFIX, + path: '/token', + hostname: 'form.example.com', + }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + + it('forms takes priority over guest-task', () => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + const req = makeRequest({ + baseUrl: FORMS_PATH_PREFIX, + path: '/guest-task/token', + url: '/guest-task/token', + }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + }); + + describe('edge cases', () => { + beforeEach(() => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + }); + + it('does NOT match /formsome as forms path (boundary check)', () => { + const req = makeRequest({ path: '/formsome/token' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); + }); + + it('does NOT match /form as forms path', () => { + const req = makeRequest({ path: '/form/token' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); + }); + + it('handles formSubdomain=undefined without crashing', () => { + mockGetConfig.mockReturnValue({ formSubdomain: undefined }); + const req = makeRequest({ path: '/dashboard' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); + }); + + it('partial hostname match — includes() matches substrings', () => { + mockGetConfig.mockReturnValue({ formSubdomain: 'form.example.com' }); + const req = makeRequest({ hostname: 'reform.example.com' }); + + // Known behavior: hostname.includes(formSubdomain) is a substring check. + // "reform.example.com".includes("form.example.com") === true + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + }); +}); diff --git a/frontend/src/public/utils/identifyAppPart/constants.ts b/frontend/src/public/utils/identifyAppPart/constants.ts index 9975b37cb..90857adcf 100644 --- a/frontend/src/public/utils/identifyAppPart/constants.ts +++ b/frontend/src/public/utils/identifyAppPart/constants.ts @@ -1 +1,14 @@ export const GUEST_URLS = ['/guest-task/']; +export const FORMS_PATH_PREFIX = '/forms'; + +/** + * Returns the basename for React Router in the forms app. + * - Path-based mode (/forms/{token}): returns '/forms' so Router strips the prefix + * - Subdomain mode (forms.domain.com/{token}): returns undefined (no prefix to strip) + */ +export function getFormsBasename(pathname: string): string | undefined { + const isPathBased = pathname.startsWith(`${FORMS_PATH_PREFIX}/`) + || pathname === FORMS_PATH_PREFIX; + + return isPathBased ? FORMS_PATH_PREFIX : undefined; +} diff --git a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts index 0a182d311..0b228afa9 100644 --- a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts +++ b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts @@ -1,17 +1,22 @@ -import { GUEST_URLS } from './constants'; +import { GUEST_URLS, getFormsBasename } from './constants'; import { EAppPart } from './types'; import { getBrowserConfig } from '../getConfig'; import { history } from '../history'; export const identifyAppPartOnClient = (): EAppPart => { + const { config: { formSubdomain } } = getBrowserConfig(); + const identifyAppPartMap = [ { - check: () => { - const { config: { formSubdomain } } = getBrowserConfig(); - - return window.location.hostname.includes(formSubdomain); - }, + // Path-based forms: domain.com/forms/* + check: () => !!getFormsBasename(window.location.pathname), + appPart: EAppPart.PublicFormApp, + }, + { + // Subdomain forms: form.domain.com/* (FORM_DOMAIN) + check: () => !!formSubdomain + && window.location.hostname.includes(formSubdomain), appPart: EAppPart.PublicFormApp, }, { diff --git a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts index 279ed37f4..ee03dd414 100644 --- a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts +++ b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts @@ -1,16 +1,21 @@ import { Request } from 'express'; -import { GUEST_URLS } from './constants'; +import { GUEST_URLS, FORMS_PATH_PREFIX } from './constants'; import { EAppPart } from './types'; import { getConfig } from '../getConfig'; export const identifyAppPartOnServer = (req: Request): EAppPart => { + const { formSubdomain } = getConfig(); + const identifyAppPartMap = [ { - check: () => { - const { formSubdomain } = getConfig(); - return req.hostname.includes(formSubdomain); - }, + // Path-based forms: domain.com/forms/* + check: () => req.baseUrl === FORMS_PATH_PREFIX || req.path.startsWith(`${FORMS_PATH_PREFIX}/`), + appPart: EAppPart.PublicFormApp, + }, + { + // Subdomain forms: form.domain.com/* (FORM_DOMAIN) + check: () => !!formSubdomain && req.hostname.includes(formSubdomain), appPart: EAppPart.PublicFormApp, }, { diff --git a/frontend/src/server/handlers/__tests__/mainHandler.test.ts b/frontend/src/server/handlers/__tests__/mainHandler.test.ts index d6924537c..d16fa5397 100644 --- a/frontend/src/server/handlers/__tests__/mainHandler.test.ts +++ b/frontend/src/server/handlers/__tests__/mainHandler.test.ts @@ -22,6 +22,8 @@ describe('handlers', () => { const req = { get: jest.fn(), url: '/', + path: '/', + baseUrl: '', headers: { 'user-agent': 'windows phone', }, @@ -31,6 +33,7 @@ describe('handlers', () => { sendFile: jest.fn(), render: jest.fn(), redirect: jest.fn(), + cookie: jest.fn(), }; const env = process.env.MCS_RUN_ENV || 'local'; jest.spyOn(serverApi, 'get').mockResolvedValueOnce({}); @@ -50,6 +53,8 @@ describe('handlers', () => { const req = { get: jest.fn(), url: '/', + path: '/', + baseUrl: '', headers: { 'user-agent': 'windows phone', }, @@ -59,6 +64,7 @@ describe('handlers', () => { sendFile: jest.fn(), render: jest.fn(), redirect: jest.fn(), + cookie: jest.fn(), }; await mainHandler(req as unknown as Request, res as unknown as Response); diff --git a/frontend/src/server/middleware/__tests__/authMiddleware.test.ts b/frontend/src/server/middleware/__tests__/authMiddleware.test.ts index 277e6a382..1e3433f5e 100644 --- a/frontend/src/server/middleware/__tests__/authMiddleware.test.ts +++ b/frontend/src/server/middleware/__tests__/authMiddleware.test.ts @@ -15,6 +15,8 @@ const req: any = { header: jest.fn(), get: jest.fn(), url: '/', + path: '/', + baseUrl: '', headers: { 'user-agent': 'windows phone', }, diff --git a/frontend/src/server/server.ts b/frontend/src/server/server.ts index cdbd80a85..f4e93467f 100644 --- a/frontend/src/server/server.ts +++ b/frontend/src/server/server.ts @@ -10,6 +10,7 @@ import { mainHandler, oAuthHandler, apiProxy } from './handlers'; import { authMiddleware, verificateAccountMiddleware, forwardForSubdomain } from './middleware'; import { getConfig, serverConfigToBrowser } from '../public/utils/getConfig'; import { ERoutes } from '../public/constants/routes'; +import { FORMS_PATH_PREFIX } from '../public/utils/identifyAppPart/constants'; import { setPublicAuthCookie } from './utils/cookie'; import { getUserPublic } from './middleware/utils/getUserPublic'; import { mapToCamelCase } from '../public/utils/mappers'; @@ -95,7 +96,14 @@ export function initServer() { return null; }); - app.use(forwardForSubdomain([formSubdomain], formsRouter)); + // Path-based forms: domain.com/forms/* (always available) + app.use(FORMS_PATH_PREFIX, formsRouter); + + // Subdomain forms: form.domain.com/* (if FORM_DOMAIN is set) + if (formSubdomain) { + app.use(forwardForSubdomain([formSubdomain], formsRouter)); + } + app.get(ERoutes.AccountVerificationLink, verificateAccountMiddleware); app.get(ERoutes.OAuthGoogle, oAuthHandler(urls.getGoogleAuthUri, urls.getGoogleAuthToken)); app.get(ERoutes.OAuthMicrosoft, oAuthHandler(urls.getMicrosoftAuthUri, urls.getMicrosoftAuthToken)); diff --git a/nginx/templates/nginx.conf.template b/nginx/templates/nginx.conf.template index bd679a96a..115496e84 100644 --- a/nginx/templates/nginx.conf.template +++ b/nginx/templates/nginx.conf.template @@ -148,6 +148,18 @@ http { proxy_cache_bypass $http_upgrade; } + # Path-based forms (single-domain mode, always available) + location /forms/ { + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_cache_bypass $http_upgrade; + } + location / { proxy_pass http://frontend; proxy_http_version 1.1; From 125e67aa03251db798987607dbe0fe6520e23990 Mon Sep 17 00:00:00 2001 From: Eugen Birkenfeld Date: Tue, 26 May 2026 07:20:33 +0500 Subject: [PATCH 2/4] 46864 feat(forms): add path-based public form routing --- backend/src/settings.py | 7 +- docker-compose.src.yml | 10 +- docker-compose.yml | 10 +- frontend/src/public/forms.tsx | 9 +- .../__tests__/getFormsBasename.test.ts | 92 +++++++++++ .../__tests__/identifyAppPartOnClient.test.ts | 142 +++++++++++++++++ .../__tests__/identifyAppPartOnServer.test.ts | 143 ++++++++++++++++++ .../public/utils/identifyAppPart/constants.ts | 13 ++ .../identifyAppPartOnClient.ts | 17 ++- .../identifyAppPartOnServer.ts | 15 +- .../handlers/__tests__/mainHandler.test.ts | 6 + .../__tests__/authMiddleware.test.ts | 2 + frontend/src/server/server.ts | 10 +- nginx/templates/nginx.conf.template | 12 ++ 14 files changed, 463 insertions(+), 25 deletions(-) create mode 100644 frontend/src/public/utils/identifyAppPart/__tests__/getFormsBasename.test.ts create mode 100644 frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts create mode 100644 frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts diff --git a/backend/src/settings.py b/backend/src/settings.py index b7704164d..5058e91e0 100644 --- a/backend/src/settings.py +++ b/backend/src/settings.py @@ -11,6 +11,7 @@ import datetime import os from os import environ as env +from urllib.parse import urlparse from configurations import Configuration, values from corsheaders.defaults import default_headers @@ -108,7 +109,11 @@ class Common(Configuration): # A list of origins that are authorized to make cross-site HTTP requests. # A list of origins echoed back to the client in the # Access-Control-Allow-Origin header. Defaults to []. - CORS_ORIGIN_WHITELIST = [FRONTEND_URL, FORMS_URL] + CORS_ORIGIN_WHITELIST = [FRONTEND_URL] + if FORMS_URL: + _forms_origin = f"{urlparse(FORMS_URL).scheme}://{urlparse(FORMS_URL).netloc}" + if _forms_origin != FRONTEND_URL: + CORS_ORIGIN_WHITELIST.append(_forms_origin) EXTRA_CORS_ORIGIN_WHITELIST = env.get('CORS_ORIGIN_WHITELIST') if EXTRA_CORS_ORIGIN_WHITELIST: CORS_ORIGIN_WHITELIST.extend(EXTRA_CORS_ORIGIN_WHITELIST.split(' ')) diff --git a/docker-compose.src.yml b/docker-compose.src.yml index 8a56efeba..da460742a 100755 --- a/docker-compose.src.yml +++ b/docker-compose.src.yml @@ -71,7 +71,7 @@ services: environment: BACKEND_URL: ${BACKEND_URL:-http://localhost:8001} FRONTEND_URL: ${FRONTEND_URL:-http://localhost} - FORMS_URL: ${FORMS_URL:-http://form.localhost} + FORMS_URL: ${FORMS_URL:-http://localhost/forms} ENVIRONMENT: ${ENVIRONMENT:-Production} RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru @@ -174,7 +174,7 @@ services: environment: BACKEND_URL: ${BACKEND_URL:-http://localhost:8001} FRONTEND_URL: ${FRONTEND_URL:-http://localhost} - FORMS_URL: ${FORMS_URL:-http://form.localhost} + FORMS_URL: ${FORMS_URL:-http://localhost/forms} ENVIRONMENT: ${ENVIRONMENT:-Production} RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru @@ -271,7 +271,7 @@ services: environment: BACKEND_URL: ${BACKEND_URL:-http://localhost:8001} FRONTEND_URL: ${FRONTEND_URL:-http://localhost} - FORMS_URL: ${FORMS_URL:-http://form.localhost} + FORMS_URL: ${FORMS_URL:-http://localhost/forms} ENVIRONMENT: ${ENVIRONMENT:-Production} RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru @@ -369,7 +369,7 @@ services: BACKEND_URL: ${BACKEND_URL:-http://localhost:8001} FRONTEND_URL: ${FRONTEND_URL:-http://localhost} WSS_URL: ${WSS_URL:-ws://localhost:8001/} - FORM_DOMAIN: ${FORM_DOMAIN:-form.localhost} + FORM_DOMAIN: ${FORM_DOMAIN:-} MCS_RUN_ENV: ${MCS_RUN_ENV:-prod} NODE_OPTIONS: ${NODE_OPTIONS:---max-old-space-size=3072} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru @@ -413,7 +413,7 @@ services: SSL: ${SSL:-no} BACKEND_DOMAIN: ${BACKEND_DOMAIN:-localhost} FRONTEND_DOMAIN: ${FRONTEND_DOMAIN:-localhost} - FORM_DOMAIN: ${FORM_DOMAIN:-form.localhost} + FORM_DOMAIN: ${FORM_DOMAIN:-_} NGINX_ENVSUBST_OUTPUT_DIR: /etc/nginx/ NGINX_ENVSUBST_TEMPLATE_DIR: /etc/nginx/templates volumes: diff --git a/docker-compose.yml b/docker-compose.yml index ea914dc76..06e27c574 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -72,7 +72,7 @@ services: environment: BACKEND_URL: ${BACKEND_URL:-http://localhost:8001} FRONTEND_URL: ${FRONTEND_URL:-http://localhost} - FORMS_URL: ${FORMS_URL:-http://form.localhost} + FORMS_URL: ${FORMS_URL:-http://localhost/forms} ENVIRONMENT: ${ENVIRONMENT:-Production} RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru @@ -175,7 +175,7 @@ services: environment: BACKEND_URL: ${BACKEND_URL:-http://localhost:8001} FRONTEND_URL: ${FRONTEND_URL:-http://localhost} - FORMS_URL: ${FORMS_URL:-http://form.localhost} + FORMS_URL: ${FORMS_URL:-http://localhost/forms} ENVIRONMENT: ${ENVIRONMENT:-Production} RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru @@ -273,7 +273,7 @@ services: environment: BACKEND_URL: ${BACKEND_URL:-http://localhost:8001} FRONTEND_URL: ${FRONTEND_URL:-http://localhost} - FORMS_URL: ${FORMS_URL:-http://form.localhost} + FORMS_URL: ${FORMS_URL:-http://localhost/forms} ENVIRONMENT: ${ENVIRONMENT:-Production} RELEASE: ${RELEASE:-1.0.0} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru @@ -372,7 +372,7 @@ services: BACKEND_URL: ${BACKEND_URL:-http://localhost:8001} FRONTEND_URL: ${FRONTEND_URL:-http://localhost} WSS_URL: ${WSS_URL:-ws://localhost:8001} - FORM_DOMAIN: ${FORM_DOMAIN:-form.localhost} + FORM_DOMAIN: ${FORM_DOMAIN:-} MCS_RUN_ENV: ${MCS_RUN_ENV:-prod} NODE_OPTIONS: ${NODE_OPTIONS:---max-old-space-size=3072} LANGUAGE_CODE: ${LANGUAGE_CODE:-en} # Allowed values: en, fr, de, es, ru @@ -415,7 +415,7 @@ services: environment: BACKEND_DOMAIN: ${BACKEND_DOMAIN:-localhost} FRONTEND_DOMAIN: ${FRONTEND_DOMAIN:-localhost} - FORM_DOMAIN: ${FORM_DOMAIN:-form.localhost} + FORM_DOMAIN: ${FORM_DOMAIN:-_} NGINX_ENVSUBST_OUTPUT_DIR: /etc/nginx/ NGINX_ENVSUBST_TEMPLATE_DIR: /etc/nginx/templates volumes: diff --git a/frontend/src/public/forms.tsx b/frontend/src/public/forms.tsx index 7a1a54e8e..348c575f6 100644 --- a/frontend/src/public/forms.tsx +++ b/frontend/src/public/forms.tsx @@ -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'; @@ -25,13 +26,17 @@ initSentry(getPublicFormConfig, 'forms'); const { config: { mainPage }, } = getPublicFormConfig(); + +const formsHistory = createBrowserHistory({ + basename: getFormsBasename(window.location.pathname), +}); const currentAppLocale = AppLocale[defaultLocale]; ReactDOM.render( }> - + { + 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); + }); + }); +}); diff --git a/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts new file mode 100644 index 000000000..f669a97b0 --- /dev/null +++ b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts @@ -0,0 +1,142 @@ +// +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 = {}) => { + 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('path-based forms', () => { + beforeEach(() => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } }); + }); + + it('returns PublicFormApp when pathname starts with FORMS_PATH_PREFIX/', () => { + setWindowLocation({ pathname: `${FORMS_PATH_PREFIX}/abc123` }); + + expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); + }); + + it('returns PublicFormApp when pathname equals FORMS_PATH_PREFIX', () => { + setWindowLocation({ pathname: FORMS_PATH_PREFIX }); + + expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); + }); + }); + + describe('subdomain forms', () => { + it('returns PublicFormApp when hostname matches formSubdomain', () => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: 'form.example.com' } }); + setWindowLocation({ hostname: 'form.example.com' }); + + expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); + }); + + it('returns MainApp when formSubdomain is empty string (no false positive)', () => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } }); + setWindowLocation({ hostname: 'localhost', pathname: '/dashboard' }); + (history as any).location.pathname = '/dashboard'; + + expect(identifyAppPartOnClient()).toBe(EAppPart.MainApp); + }); + }); + + 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); + }); + }); + + describe('priority order', () => { + it('path-based forms takes priority over subdomain', () => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: 'form.example.com' } }); + setWindowLocation({ + pathname: `${FORMS_PATH_PREFIX}/token`, + hostname: 'form.example.com', + }); + + expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); + }); + }); + + 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 — includes() matches substrings', () => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: 'form.example.com' } }); + setWindowLocation({ hostname: 'reform.example.com' }); + + expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); + }); + }); +}); diff --git a/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts new file mode 100644 index 000000000..1dde75bf6 --- /dev/null +++ b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts @@ -0,0 +1,143 @@ +// +import { identifyAppPartOnServer } from '../identifyAppPartOnServer'; +import { EAppPart } from '../types'; +import { FORMS_PATH_PREFIX } from '../constants'; + +jest.mock('../../getConfig', () => ({ + getConfig: jest.fn(), +})); + +import { getConfig } from '../../getConfig'; + +const makeRequest = (overrides: Record = {}) => ({ + baseUrl: '', + path: '/', + hostname: 'localhost', + url: '/', + headers: {}, + ...overrides, +} as any); + +describe('identifyAppPartOnServer', () => { + const mockGetConfig = getConfig as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('path-based forms', () => { + beforeEach(() => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + }); + + it('returns PublicFormApp when baseUrl is FORMS_PATH_PREFIX', () => { + const req = makeRequest({ baseUrl: FORMS_PATH_PREFIX, path: '/abc123' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + + it('returns PublicFormApp when path starts with FORMS_PATH_PREFIX/', () => { + const req = makeRequest({ path: `${FORMS_PATH_PREFIX}/abc123` }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + }); + + describe('subdomain forms', () => { + it('returns PublicFormApp when hostname matches formSubdomain', () => { + mockGetConfig.mockReturnValue({ formSubdomain: 'form.example.com' }); + const req = makeRequest({ hostname: 'form.example.com' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + + it('returns MainApp when formSubdomain is empty string (no false positive)', () => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + const req = makeRequest({ hostname: 'localhost', path: '/dashboard' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); + }); + }); + + describe('guest task', () => { + beforeEach(() => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + }); + + it('returns GuestTaskApp when url contains /guest-task/', () => { + const req = makeRequest({ url: '/guest-task/some-token' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.GuestTaskApp); + }); + }); + + describe('main app fallback', () => { + beforeEach(() => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + }); + + it('returns MainApp for regular paths', () => { + const req = makeRequest({ path: '/dashboard' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); + }); + }); + + describe('priority order', () => { + it('path-based forms takes priority over subdomain', () => { + mockGetConfig.mockReturnValue({ formSubdomain: 'form.example.com' }); + const req = makeRequest({ + baseUrl: FORMS_PATH_PREFIX, + path: '/token', + hostname: 'form.example.com', + }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + + it('forms takes priority over guest-task', () => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + const req = makeRequest({ + baseUrl: FORMS_PATH_PREFIX, + path: '/guest-task/token', + url: '/guest-task/token', + }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + }); + + describe('edge cases', () => { + beforeEach(() => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + }); + + it('does NOT match /formsome as forms path (boundary check)', () => { + const req = makeRequest({ path: '/formsome/token' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); + }); + + it('does NOT match /form as forms path', () => { + const req = makeRequest({ path: '/form/token' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); + }); + + it('handles formSubdomain=undefined without crashing', () => { + mockGetConfig.mockReturnValue({ formSubdomain: undefined }); + const req = makeRequest({ path: '/dashboard' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); + }); + + it('partial hostname match — includes() matches substrings', () => { + mockGetConfig.mockReturnValue({ formSubdomain: 'form.example.com' }); + const req = makeRequest({ hostname: 'reform.example.com' }); + + // Known behavior: hostname.includes(formSubdomain) is a substring check. + // "reform.example.com".includes("form.example.com") === true + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + }); +}); diff --git a/frontend/src/public/utils/identifyAppPart/constants.ts b/frontend/src/public/utils/identifyAppPart/constants.ts index 9975b37cb..90857adcf 100644 --- a/frontend/src/public/utils/identifyAppPart/constants.ts +++ b/frontend/src/public/utils/identifyAppPart/constants.ts @@ -1 +1,14 @@ export const GUEST_URLS = ['/guest-task/']; +export const FORMS_PATH_PREFIX = '/forms'; + +/** + * Returns the basename for React Router in the forms app. + * - Path-based mode (/forms/{token}): returns '/forms' so Router strips the prefix + * - Subdomain mode (forms.domain.com/{token}): returns undefined (no prefix to strip) + */ +export function getFormsBasename(pathname: string): string | undefined { + const isPathBased = pathname.startsWith(`${FORMS_PATH_PREFIX}/`) + || pathname === FORMS_PATH_PREFIX; + + return isPathBased ? FORMS_PATH_PREFIX : undefined; +} diff --git a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts index 0a182d311..0b228afa9 100644 --- a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts +++ b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts @@ -1,17 +1,22 @@ -import { GUEST_URLS } from './constants'; +import { GUEST_URLS, getFormsBasename } from './constants'; import { EAppPart } from './types'; import { getBrowserConfig } from '../getConfig'; import { history } from '../history'; export const identifyAppPartOnClient = (): EAppPart => { + const { config: { formSubdomain } } = getBrowserConfig(); + const identifyAppPartMap = [ { - check: () => { - const { config: { formSubdomain } } = getBrowserConfig(); - - return window.location.hostname.includes(formSubdomain); - }, + // Path-based forms: domain.com/forms/* + check: () => !!getFormsBasename(window.location.pathname), + appPart: EAppPart.PublicFormApp, + }, + { + // Subdomain forms: form.domain.com/* (FORM_DOMAIN) + check: () => !!formSubdomain + && window.location.hostname.includes(formSubdomain), appPart: EAppPart.PublicFormApp, }, { diff --git a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts index 279ed37f4..ee03dd414 100644 --- a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts +++ b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts @@ -1,16 +1,21 @@ import { Request } from 'express'; -import { GUEST_URLS } from './constants'; +import { GUEST_URLS, FORMS_PATH_PREFIX } from './constants'; import { EAppPart } from './types'; import { getConfig } from '../getConfig'; export const identifyAppPartOnServer = (req: Request): EAppPart => { + const { formSubdomain } = getConfig(); + const identifyAppPartMap = [ { - check: () => { - const { formSubdomain } = getConfig(); - return req.hostname.includes(formSubdomain); - }, + // Path-based forms: domain.com/forms/* + check: () => req.baseUrl === FORMS_PATH_PREFIX || req.path.startsWith(`${FORMS_PATH_PREFIX}/`), + appPart: EAppPart.PublicFormApp, + }, + { + // Subdomain forms: form.domain.com/* (FORM_DOMAIN) + check: () => !!formSubdomain && req.hostname.includes(formSubdomain), appPart: EAppPart.PublicFormApp, }, { diff --git a/frontend/src/server/handlers/__tests__/mainHandler.test.ts b/frontend/src/server/handlers/__tests__/mainHandler.test.ts index d6924537c..d16fa5397 100644 --- a/frontend/src/server/handlers/__tests__/mainHandler.test.ts +++ b/frontend/src/server/handlers/__tests__/mainHandler.test.ts @@ -22,6 +22,8 @@ describe('handlers', () => { const req = { get: jest.fn(), url: '/', + path: '/', + baseUrl: '', headers: { 'user-agent': 'windows phone', }, @@ -31,6 +33,7 @@ describe('handlers', () => { sendFile: jest.fn(), render: jest.fn(), redirect: jest.fn(), + cookie: jest.fn(), }; const env = process.env.MCS_RUN_ENV || 'local'; jest.spyOn(serverApi, 'get').mockResolvedValueOnce({}); @@ -50,6 +53,8 @@ describe('handlers', () => { const req = { get: jest.fn(), url: '/', + path: '/', + baseUrl: '', headers: { 'user-agent': 'windows phone', }, @@ -59,6 +64,7 @@ describe('handlers', () => { sendFile: jest.fn(), render: jest.fn(), redirect: jest.fn(), + cookie: jest.fn(), }; await mainHandler(req as unknown as Request, res as unknown as Response); diff --git a/frontend/src/server/middleware/__tests__/authMiddleware.test.ts b/frontend/src/server/middleware/__tests__/authMiddleware.test.ts index 277e6a382..1e3433f5e 100644 --- a/frontend/src/server/middleware/__tests__/authMiddleware.test.ts +++ b/frontend/src/server/middleware/__tests__/authMiddleware.test.ts @@ -15,6 +15,8 @@ const req: any = { header: jest.fn(), get: jest.fn(), url: '/', + path: '/', + baseUrl: '', headers: { 'user-agent': 'windows phone', }, diff --git a/frontend/src/server/server.ts b/frontend/src/server/server.ts index cdbd80a85..f4e93467f 100644 --- a/frontend/src/server/server.ts +++ b/frontend/src/server/server.ts @@ -10,6 +10,7 @@ import { mainHandler, oAuthHandler, apiProxy } from './handlers'; import { authMiddleware, verificateAccountMiddleware, forwardForSubdomain } from './middleware'; import { getConfig, serverConfigToBrowser } from '../public/utils/getConfig'; import { ERoutes } from '../public/constants/routes'; +import { FORMS_PATH_PREFIX } from '../public/utils/identifyAppPart/constants'; import { setPublicAuthCookie } from './utils/cookie'; import { getUserPublic } from './middleware/utils/getUserPublic'; import { mapToCamelCase } from '../public/utils/mappers'; @@ -95,7 +96,14 @@ export function initServer() { return null; }); - app.use(forwardForSubdomain([formSubdomain], formsRouter)); + // Path-based forms: domain.com/forms/* (always available) + app.use(FORMS_PATH_PREFIX, formsRouter); + + // Subdomain forms: form.domain.com/* (if FORM_DOMAIN is set) + if (formSubdomain) { + app.use(forwardForSubdomain([formSubdomain], formsRouter)); + } + app.get(ERoutes.AccountVerificationLink, verificateAccountMiddleware); app.get(ERoutes.OAuthGoogle, oAuthHandler(urls.getGoogleAuthUri, urls.getGoogleAuthToken)); app.get(ERoutes.OAuthMicrosoft, oAuthHandler(urls.getMicrosoftAuthUri, urls.getMicrosoftAuthToken)); diff --git a/nginx/templates/nginx.conf.template b/nginx/templates/nginx.conf.template index bd679a96a..115496e84 100644 --- a/nginx/templates/nginx.conf.template +++ b/nginx/templates/nginx.conf.template @@ -148,6 +148,18 @@ http { proxy_cache_bypass $http_upgrade; } + # Path-based forms (single-domain mode, always available) + location /forms/ { + proxy_pass http://frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_cache_bypass $http_upgrade; + } + location / { proxy_pass http://frontend; proxy_http_version 1.1; From 9662566e79a5476e9f80aedd153e4e715fbd4393 Mon Sep 17 00:00:00 2001 From: Eugen Birkenfeld Date: Fri, 29 May 2026 01:33:21 +0500 Subject: [PATCH 3/4] 46864 refactor(forms): extract shared isFormPath helper and unify form detection --- frontend/src/public/forms.tsx | 2 +- .../__tests__/identifyAppPartOnClient.test.ts | 40 ++++++------- .../__tests__/identifyAppPartOnServer.test.ts | 58 +++++++++--------- .../__tests__/isFormPath.test.ts | 60 +++++++++++++++++++ .../public/utils/identifyAppPart/constants.ts | 19 ++++++ .../identifyAppPartOnClient.ts | 16 +++-- .../identifyAppPartOnServer.ts | 13 ++-- 7 files changed, 136 insertions(+), 72 deletions(-) create mode 100644 frontend/src/public/utils/identifyAppPart/__tests__/isFormPath.test.ts diff --git a/frontend/src/public/forms.tsx b/frontend/src/public/forms.tsx index 348c575f6..5ddc04f30 100644 --- a/frontend/src/public/forms.tsx +++ b/frontend/src/public/forms.tsx @@ -28,7 +28,7 @@ const { } = getPublicFormConfig(); const formsHistory = createBrowserHistory({ - basename: getFormsBasename(window.location.pathname), + basename: getFormsBasename(window.location.pathname) || '/', }); const currentAppLocale = AppLocale[defaultLocale]; diff --git a/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts index f669a97b0..7add32575 100644 --- a/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts +++ b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts @@ -35,38 +35,36 @@ describe('identifyAppPartOnClient', () => { (history as any).location.pathname = '/'; }); - describe('path-based forms', () => { - beforeEach(() => { + describe('forms detection', () => { + it('returns PublicFormApp for path-based forms (/forms/*)', () => { mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } }); - }); - - it('returns PublicFormApp when pathname starts with FORMS_PATH_PREFIX/', () => { setWindowLocation({ pathname: `${FORMS_PATH_PREFIX}/abc123` }); expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); }); - it('returns PublicFormApp when pathname equals FORMS_PATH_PREFIX', () => { + it('returns PublicFormApp when pathname equals /forms', () => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } }); setWindowLocation({ pathname: FORMS_PATH_PREFIX }); expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); }); - }); - describe('subdomain forms', () => { - it('returns PublicFormApp when hostname matches formSubdomain', () => { + 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 MainApp when formSubdomain is empty string (no false positive)', () => { - mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } }); - setWindowLocation({ hostname: 'localhost', pathname: '/dashboard' }); - (history as any).location.pathname = '/dashboard'; + 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.MainApp); + expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); }); }); @@ -93,17 +91,13 @@ describe('identifyAppPartOnClient', () => { expect(identifyAppPartOnClient()).toBe(EAppPart.MainApp); }); - }); - describe('priority order', () => { - it('path-based forms takes priority over subdomain', () => { - mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: 'form.example.com' } }); - setWindowLocation({ - pathname: `${FORMS_PATH_PREFIX}/token`, - hostname: 'form.example.com', - }); + 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.PublicFormApp); + expect(identifyAppPartOnClient()).toBe(EAppPart.MainApp); }); }); diff --git a/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts index 1dde75bf6..71f944e4c 100644 --- a/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts +++ b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts @@ -25,32 +25,39 @@ describe('identifyAppPartOnServer', () => { jest.clearAllMocks(); }); - describe('path-based forms', () => { - beforeEach(() => { - mockGetConfig.mockReturnValue({ formSubdomain: '' }); - }); - + describe('forms detection', () => { it('returns PublicFormApp when baseUrl is FORMS_PATH_PREFIX', () => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); const req = makeRequest({ baseUrl: FORMS_PATH_PREFIX, path: '/abc123' }); expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); }); it('returns PublicFormApp when path starts with FORMS_PATH_PREFIX/', () => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); const req = makeRequest({ path: `${FORMS_PATH_PREFIX}/abc123` }); expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); }); - }); - describe('subdomain forms', () => { - it('returns PublicFormApp when hostname matches formSubdomain', () => { + it('returns PublicFormApp for subdomain forms', () => { mockGetConfig.mockReturnValue({ formSubdomain: 'form.example.com' }); const req = makeRequest({ hostname: 'form.example.com' }); expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); }); + it('returns PublicFormApp when both path and subdomain match', () => { + mockGetConfig.mockReturnValue({ formSubdomain: 'form.example.com' }); + const req = makeRequest({ + baseUrl: FORMS_PATH_PREFIX, + path: '/token', + hostname: 'form.example.com', + }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + it('returns MainApp when formSubdomain is empty string (no false positive)', () => { mockGetConfig.mockReturnValue({ formSubdomain: '' }); const req = makeRequest({ hostname: 'localhost', path: '/dashboard' }); @@ -71,30 +78,7 @@ describe('identifyAppPartOnServer', () => { }); }); - describe('main app fallback', () => { - beforeEach(() => { - mockGetConfig.mockReturnValue({ formSubdomain: '' }); - }); - - it('returns MainApp for regular paths', () => { - const req = makeRequest({ path: '/dashboard' }); - - expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); - }); - }); - describe('priority order', () => { - it('path-based forms takes priority over subdomain', () => { - mockGetConfig.mockReturnValue({ formSubdomain: 'form.example.com' }); - const req = makeRequest({ - baseUrl: FORMS_PATH_PREFIX, - path: '/token', - hostname: 'form.example.com', - }); - - expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); - }); - it('forms takes priority over guest-task', () => { mockGetConfig.mockReturnValue({ formSubdomain: '' }); const req = makeRequest({ @@ -107,6 +91,18 @@ describe('identifyAppPartOnServer', () => { }); }); + describe('main app fallback', () => { + beforeEach(() => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + }); + + it('returns MainApp for regular paths', () => { + const req = makeRequest({ path: '/dashboard' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); + }); + }); + describe('edge cases', () => { beforeEach(() => { mockGetConfig.mockReturnValue({ formSubdomain: '' }); diff --git a/frontend/src/public/utils/identifyAppPart/__tests__/isFormPath.test.ts b/frontend/src/public/utils/identifyAppPart/__tests__/isFormPath.test.ts new file mode 100644 index 000000000..56740e609 --- /dev/null +++ b/frontend/src/public/utils/identifyAppPart/__tests__/isFormPath.test.ts @@ -0,0 +1,60 @@ +// +import { isFormPath, FORMS_PATH_PREFIX } from '../constants'; + +describe('isFormPath', () => { + describe('path-based detection', () => { + it('returns true when pathname starts with /forms/', () => { + expect(isFormPath('localhost', '/forms/abc123', '')).toBe(true); + }); + + it('returns true when pathname equals /forms', () => { + expect(isFormPath('localhost', FORMS_PATH_PREFIX, '')).toBe(true); + }); + + it('returns true for nested path /forms/embed/abc', () => { + expect(isFormPath('localhost', '/forms/embed/abc', '')).toBe(true); + }); + + it('returns false for /formsome (boundary check)', () => { + expect(isFormPath('localhost', '/formsome', '')).toBe(false); + }); + + it('returns false for /form (shorter than /forms)', () => { + expect(isFormPath('localhost', '/form', '')).toBe(false); + }); + }); + + describe('subdomain detection', () => { + it('returns true when hostname includes formSubdomain', () => { + expect(isFormPath('form.example.com', '/', 'form.example.com')).toBe(true); + }); + + it('returns false when formSubdomain is empty string', () => { + expect(isFormPath('localhost', '/', '')).toBe(false); + }); + + it('returns false when formSubdomain is undefined', () => { + expect(isFormPath('localhost', '/', undefined)).toBe(false); + }); + + it('returns true for partial hostname match (includes behavior)', () => { + expect(isFormPath('reform.example.com', '/', 'form.example.com')).toBe(true); + }); + }); + + describe('combined — both match', () => { + it('returns true when both path and subdomain match', () => { + expect(isFormPath('form.example.com', '/forms/token', 'form.example.com')).toBe(true); + }); + }); + + describe('fallback — neither match', () => { + it('returns false for regular path with no subdomain', () => { + expect(isFormPath('localhost', '/dashboard', '')).toBe(false); + }); + + it('returns false for root path with no subdomain', () => { + expect(isFormPath('localhost', '/', '')).toBe(false); + }); + }); +}); diff --git a/frontend/src/public/utils/identifyAppPart/constants.ts b/frontend/src/public/utils/identifyAppPart/constants.ts index 90857adcf..62a809dbe 100644 --- a/frontend/src/public/utils/identifyAppPart/constants.ts +++ b/frontend/src/public/utils/identifyAppPart/constants.ts @@ -1,6 +1,25 @@ export const GUEST_URLS = ['/guest-task/']; export const FORMS_PATH_PREFIX = '/forms'; +/** + * Checks whether the current request targets the public forms app. + * Combines both detection strategies: + * - Path-based: domain.com/forms/* + * - Subdomain: form.domain.com/* + */ +export function isFormPath( + hostname: string, + pathname: string, + formSubdomain: string | undefined, +): boolean { + const isPathBased = pathname.startsWith(`${FORMS_PATH_PREFIX}/`) + || pathname === FORMS_PATH_PREFIX; + + const isSubdomain = !!formSubdomain && hostname.includes(formSubdomain); + + return isPathBased || isSubdomain; +} + /** * Returns the basename for React Router in the forms app. * - Path-based mode (/forms/{token}): returns '/forms' so Router strips the prefix diff --git a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts index 0b228afa9..d9e86408e 100644 --- a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts +++ b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts @@ -1,4 +1,4 @@ -import { GUEST_URLS, getFormsBasename } from './constants'; +import { GUEST_URLS, isFormPath } from './constants'; import { EAppPart } from './types'; import { getBrowserConfig } from '../getConfig'; @@ -9,14 +9,12 @@ export const identifyAppPartOnClient = (): EAppPart => { const identifyAppPartMap = [ { - // Path-based forms: domain.com/forms/* - check: () => !!getFormsBasename(window.location.pathname), - appPart: EAppPart.PublicFormApp, - }, - { - // Subdomain forms: form.domain.com/* (FORM_DOMAIN) - check: () => !!formSubdomain - && window.location.hostname.includes(formSubdomain), + // Forms: path-based (domain.com/forms/*) or subdomain (form.domain.com/*) + check: () => isFormPath( + window.location.hostname, + window.location.pathname, + formSubdomain, + ), appPart: EAppPart.PublicFormApp, }, { diff --git a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts index ee03dd414..b8d319b18 100644 --- a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts +++ b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts @@ -1,5 +1,5 @@ import { Request } from 'express'; -import { GUEST_URLS, FORMS_PATH_PREFIX } from './constants'; +import { GUEST_URLS, FORMS_PATH_PREFIX, isFormPath } from './constants'; import { EAppPart } from './types'; import { getConfig } from '../getConfig'; @@ -9,13 +9,10 @@ export const identifyAppPartOnServer = (req: Request): EAppPart => { const identifyAppPartMap = [ { - // Path-based forms: domain.com/forms/* - check: () => req.baseUrl === FORMS_PATH_PREFIX || req.path.startsWith(`${FORMS_PATH_PREFIX}/`), - appPart: EAppPart.PublicFormApp, - }, - { - // Subdomain forms: form.domain.com/* (FORM_DOMAIN) - check: () => !!formSubdomain && req.hostname.includes(formSubdomain), + // Forms: path-based (domain.com/forms/*), subdomain (form.domain.com/*), + // or Express-mounted sub-app at /forms + check: () => req.baseUrl === FORMS_PATH_PREFIX + || isFormPath(req.hostname, req.path, formSubdomain), appPart: EAppPart.PublicFormApp, }, { From dc7f0d3842c054a0e82535b3b261d567b6ffca38 Mon Sep 17 00:00:00 2001 From: Eugen Birkenfeld Date: Fri, 29 May 2026 01:33:21 +0500 Subject: [PATCH 4/4] 46864 refactor(forms): extract shared isFormPath helper and unify form detection --- frontend/src/public/forms.tsx | 2 +- .../__tests__/identifyAppPartOnClient.test.ts | 40 +++++------ .../__tests__/identifyAppPartOnServer.test.ts | 66 +++++++++---------- .../__tests__/isFormPath.test.ts | 60 +++++++++++++++++ .../public/utils/identifyAppPart/constants.ts | 19 ++++++ .../identifyAppPartOnClient.ts | 16 ++--- .../identifyAppPartOnServer.ts | 13 ++-- 7 files changed, 140 insertions(+), 76 deletions(-) create mode 100644 frontend/src/public/utils/identifyAppPart/__tests__/isFormPath.test.ts diff --git a/frontend/src/public/forms.tsx b/frontend/src/public/forms.tsx index 348c575f6..5ddc04f30 100644 --- a/frontend/src/public/forms.tsx +++ b/frontend/src/public/forms.tsx @@ -28,7 +28,7 @@ const { } = getPublicFormConfig(); const formsHistory = createBrowserHistory({ - basename: getFormsBasename(window.location.pathname), + basename: getFormsBasename(window.location.pathname) || '/', }); const currentAppLocale = AppLocale[defaultLocale]; diff --git a/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts index f669a97b0..7add32575 100644 --- a/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts +++ b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnClient.test.ts @@ -35,38 +35,36 @@ describe('identifyAppPartOnClient', () => { (history as any).location.pathname = '/'; }); - describe('path-based forms', () => { - beforeEach(() => { + describe('forms detection', () => { + it('returns PublicFormApp for path-based forms (/forms/*)', () => { mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } }); - }); - - it('returns PublicFormApp when pathname starts with FORMS_PATH_PREFIX/', () => { setWindowLocation({ pathname: `${FORMS_PATH_PREFIX}/abc123` }); expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); }); - it('returns PublicFormApp when pathname equals FORMS_PATH_PREFIX', () => { + it('returns PublicFormApp when pathname equals /forms', () => { + mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } }); setWindowLocation({ pathname: FORMS_PATH_PREFIX }); expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); }); - }); - describe('subdomain forms', () => { - it('returns PublicFormApp when hostname matches formSubdomain', () => { + 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 MainApp when formSubdomain is empty string (no false positive)', () => { - mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: '' } }); - setWindowLocation({ hostname: 'localhost', pathname: '/dashboard' }); - (history as any).location.pathname = '/dashboard'; + 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.MainApp); + expect(identifyAppPartOnClient()).toBe(EAppPart.PublicFormApp); }); }); @@ -93,17 +91,13 @@ describe('identifyAppPartOnClient', () => { expect(identifyAppPartOnClient()).toBe(EAppPart.MainApp); }); - }); - describe('priority order', () => { - it('path-based forms takes priority over subdomain', () => { - mockGetBrowserConfig.mockReturnValue({ config: { formSubdomain: 'form.example.com' } }); - setWindowLocation({ - pathname: `${FORMS_PATH_PREFIX}/token`, - hostname: 'form.example.com', - }); + 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.PublicFormApp); + expect(identifyAppPartOnClient()).toBe(EAppPart.MainApp); }); }); diff --git a/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts index 1dde75bf6..c678f5d08 100644 --- a/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts +++ b/frontend/src/public/utils/identifyAppPart/__tests__/identifyAppPartOnServer.test.ts @@ -25,32 +25,39 @@ describe('identifyAppPartOnServer', () => { jest.clearAllMocks(); }); - describe('path-based forms', () => { - beforeEach(() => { - mockGetConfig.mockReturnValue({ formSubdomain: '' }); - }); - + describe('forms detection', () => { it('returns PublicFormApp when baseUrl is FORMS_PATH_PREFIX', () => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); const req = makeRequest({ baseUrl: FORMS_PATH_PREFIX, path: '/abc123' }); expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); }); it('returns PublicFormApp when path starts with FORMS_PATH_PREFIX/', () => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); const req = makeRequest({ path: `${FORMS_PATH_PREFIX}/abc123` }); expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); }); - }); - describe('subdomain forms', () => { - it('returns PublicFormApp when hostname matches formSubdomain', () => { + it('returns PublicFormApp for subdomain forms', () => { mockGetConfig.mockReturnValue({ formSubdomain: 'form.example.com' }); const req = makeRequest({ hostname: 'form.example.com' }); expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); }); + it('returns PublicFormApp when both path and subdomain match', () => { + mockGetConfig.mockReturnValue({ formSubdomain: 'form.example.com' }); + const req = makeRequest({ + baseUrl: FORMS_PATH_PREFIX, + path: '/token', + hostname: 'form.example.com', + }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + }); + it('returns MainApp when formSubdomain is empty string (no false positive)', () => { mockGetConfig.mockReturnValue({ formSubdomain: '' }); const req = makeRequest({ hostname: 'localhost', path: '/dashboard' }); @@ -71,30 +78,7 @@ describe('identifyAppPartOnServer', () => { }); }); - describe('main app fallback', () => { - beforeEach(() => { - mockGetConfig.mockReturnValue({ formSubdomain: '' }); - }); - - it('returns MainApp for regular paths', () => { - const req = makeRequest({ path: '/dashboard' }); - - expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); - }); - }); - describe('priority order', () => { - it('path-based forms takes priority over subdomain', () => { - mockGetConfig.mockReturnValue({ formSubdomain: 'form.example.com' }); - const req = makeRequest({ - baseUrl: FORMS_PATH_PREFIX, - path: '/token', - hostname: 'form.example.com', - }); - - expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); - }); - it('forms takes priority over guest-task', () => { mockGetConfig.mockReturnValue({ formSubdomain: '' }); const req = makeRequest({ @@ -107,6 +91,18 @@ describe('identifyAppPartOnServer', () => { }); }); + describe('main app fallback', () => { + beforeEach(() => { + mockGetConfig.mockReturnValue({ formSubdomain: '' }); + }); + + it('returns MainApp for regular paths', () => { + const req = makeRequest({ path: '/dashboard' }); + + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); + }); + }); + describe('edge cases', () => { beforeEach(() => { mockGetConfig.mockReturnValue({ formSubdomain: '' }); @@ -131,13 +127,13 @@ describe('identifyAppPartOnServer', () => { expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); }); - it('partial hostname match — includes() matches substrings', () => { + it('partial hostname match — exact comparison rejects substrings', () => { mockGetConfig.mockReturnValue({ formSubdomain: 'form.example.com' }); const req = makeRequest({ hostname: 'reform.example.com' }); - // Known behavior: hostname.includes(formSubdomain) is a substring check. - // "reform.example.com".includes("form.example.com") === true - expect(identifyAppPartOnServer(req)).toBe(EAppPart.PublicFormApp); + // hostname === formSubdomain ensures no false positives + // "reform.example.com" !== "form.example.com" + expect(identifyAppPartOnServer(req)).toBe(EAppPart.MainApp); }); }); }); diff --git a/frontend/src/public/utils/identifyAppPart/__tests__/isFormPath.test.ts b/frontend/src/public/utils/identifyAppPart/__tests__/isFormPath.test.ts new file mode 100644 index 000000000..e9b86bc2c --- /dev/null +++ b/frontend/src/public/utils/identifyAppPart/__tests__/isFormPath.test.ts @@ -0,0 +1,60 @@ +// +import { isFormPath, FORMS_PATH_PREFIX } from '../constants'; + +describe('isFormPath', () => { + describe('path-based detection', () => { + it('returns true when pathname starts with /forms/', () => { + expect(isFormPath('localhost', '/forms/abc123', '')).toBe(true); + }); + + it('returns true when pathname equals /forms', () => { + expect(isFormPath('localhost', FORMS_PATH_PREFIX, '')).toBe(true); + }); + + it('returns true for nested path /forms/embed/abc', () => { + expect(isFormPath('localhost', '/forms/embed/abc', '')).toBe(true); + }); + + it('returns false for /formsome (boundary check)', () => { + expect(isFormPath('localhost', '/formsome', '')).toBe(false); + }); + + it('returns false for /form (shorter than /forms)', () => { + expect(isFormPath('localhost', '/form', '')).toBe(false); + }); + }); + + describe('subdomain detection', () => { + it('returns true when hostname includes formSubdomain', () => { + expect(isFormPath('form.example.com', '/', 'form.example.com')).toBe(true); + }); + + it('returns false when formSubdomain is empty string', () => { + expect(isFormPath('localhost', '/', '')).toBe(false); + }); + + it('returns false when formSubdomain is undefined', () => { + expect(isFormPath('localhost', '/', undefined)).toBe(false); + }); + + it('returns false for partial hostname match (exact comparison)', () => { + expect(isFormPath('reform.example.com', '/', 'form.example.com')).toBe(false); + }); + }); + + describe('combined — both match', () => { + it('returns true when both path and subdomain match', () => { + expect(isFormPath('form.example.com', '/forms/token', 'form.example.com')).toBe(true); + }); + }); + + describe('fallback — neither match', () => { + it('returns false for regular path with no subdomain', () => { + expect(isFormPath('localhost', '/dashboard', '')).toBe(false); + }); + + it('returns false for root path with no subdomain', () => { + expect(isFormPath('localhost', '/', '')).toBe(false); + }); + }); +}); diff --git a/frontend/src/public/utils/identifyAppPart/constants.ts b/frontend/src/public/utils/identifyAppPart/constants.ts index 90857adcf..62d3a50b2 100644 --- a/frontend/src/public/utils/identifyAppPart/constants.ts +++ b/frontend/src/public/utils/identifyAppPart/constants.ts @@ -1,6 +1,25 @@ export const GUEST_URLS = ['/guest-task/']; export const FORMS_PATH_PREFIX = '/forms'; +/** + * Checks whether the current request targets the public forms app. + * Combines both detection strategies: + * - Path-based: domain.com/forms/* + * - Subdomain: form.domain.com/* + */ +export function isFormPath( + hostname: string, + pathname: string, + formSubdomain: string | undefined, +): boolean { + const isPathBased = pathname.startsWith(`${FORMS_PATH_PREFIX}/`) + || pathname === FORMS_PATH_PREFIX; + + const isSubdomain = !!formSubdomain && hostname === formSubdomain; + + return isPathBased || isSubdomain; +} + /** * Returns the basename for React Router in the forms app. * - Path-based mode (/forms/{token}): returns '/forms' so Router strips the prefix diff --git a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts index 0b228afa9..d9e86408e 100644 --- a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts +++ b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnClient.ts @@ -1,4 +1,4 @@ -import { GUEST_URLS, getFormsBasename } from './constants'; +import { GUEST_URLS, isFormPath } from './constants'; import { EAppPart } from './types'; import { getBrowserConfig } from '../getConfig'; @@ -9,14 +9,12 @@ export const identifyAppPartOnClient = (): EAppPart => { const identifyAppPartMap = [ { - // Path-based forms: domain.com/forms/* - check: () => !!getFormsBasename(window.location.pathname), - appPart: EAppPart.PublicFormApp, - }, - { - // Subdomain forms: form.domain.com/* (FORM_DOMAIN) - check: () => !!formSubdomain - && window.location.hostname.includes(formSubdomain), + // Forms: path-based (domain.com/forms/*) or subdomain (form.domain.com/*) + check: () => isFormPath( + window.location.hostname, + window.location.pathname, + formSubdomain, + ), appPart: EAppPart.PublicFormApp, }, { diff --git a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts index ee03dd414..b8d319b18 100644 --- a/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts +++ b/frontend/src/public/utils/identifyAppPart/identifyAppPartOnServer.ts @@ -1,5 +1,5 @@ import { Request } from 'express'; -import { GUEST_URLS, FORMS_PATH_PREFIX } from './constants'; +import { GUEST_URLS, FORMS_PATH_PREFIX, isFormPath } from './constants'; import { EAppPart } from './types'; import { getConfig } from '../getConfig'; @@ -9,13 +9,10 @@ export const identifyAppPartOnServer = (req: Request): EAppPart => { const identifyAppPartMap = [ { - // Path-based forms: domain.com/forms/* - check: () => req.baseUrl === FORMS_PATH_PREFIX || req.path.startsWith(`${FORMS_PATH_PREFIX}/`), - appPart: EAppPart.PublicFormApp, - }, - { - // Subdomain forms: form.domain.com/* (FORM_DOMAIN) - check: () => !!formSubdomain && req.hostname.includes(formSubdomain), + // Forms: path-based (domain.com/forms/*), subdomain (form.domain.com/*), + // or Express-mounted sub-app at /forms + check: () => req.baseUrl === FORMS_PATH_PREFIX + || isFormPath(req.hostname, req.path, formSubdomain), appPart: EAppPart.PublicFormApp, }, {