From 1afdc13d38d7560c23a0447099cde8d42cf04069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Thu, 9 Apr 2026 13:03:16 +0100 Subject: [PATCH 1/5] WIP --- packages/blocks/http/jest.config.ts | 11 ++ packages/blocks/http/project.json | 7 ++ .../lib/actions/send-http-request-action.ts | 11 +- .../src/lib/common/webhook-url-validator.ts | 50 +++++++++ .../http/test/webhook-url-validator.test.ts | 101 ++++++++++++++++++ packages/blocks/http/tsconfig.spec.json | 14 +++ .../shared/src/lib/host-validation/index.ts | 24 ----- .../shared/test/host-validation/index.test.ts | 77 +------------ 8 files changed, 189 insertions(+), 106 deletions(-) create mode 100644 packages/blocks/http/jest.config.ts create mode 100644 packages/blocks/http/src/lib/common/webhook-url-validator.ts create mode 100644 packages/blocks/http/test/webhook-url-validator.test.ts create mode 100644 packages/blocks/http/tsconfig.spec.json diff --git a/packages/blocks/http/jest.config.ts b/packages/blocks/http/jest.config.ts new file mode 100644 index 0000000000..68afe2eafc --- /dev/null +++ b/packages/blocks/http/jest.config.ts @@ -0,0 +1,11 @@ +export default { + displayName: 'blocks-http', + preset: '../../../jest.preset.js', + setupFiles: ['../../../jest.env.js'], + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/packages/blocks/http', +}; diff --git a/packages/blocks/http/project.json b/packages/blocks/http/project.json index b361831a7f..61ba599cbd 100644 --- a/packages/blocks/http/project.json +++ b/packages/blocks/http/project.json @@ -17,6 +17,13 @@ "updateBuildableProjectDepsInPackageJson": true } }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "packages/blocks/http/jest.config.ts" + } + }, "lint": { "executor": "@nx/eslint:lint", "outputs": ["{options.outputFile}"] diff --git a/packages/blocks/http/src/lib/actions/send-http-request-action.ts b/packages/blocks/http/src/lib/actions/send-http-request-action.ts index 9e14c955bb..c266202928 100644 --- a/packages/blocks/http/src/lib/actions/send-http-request-action.ts +++ b/packages/blocks/http/src/lib/actions/send-http-request-action.ts @@ -10,13 +10,14 @@ import { DynamicPropsValue, Property, } from '@openops/blocks-framework'; -import { validateHostAllowingPublicWebhookUrl } from '@openops/server-shared'; +import { validateHost } from '@openops/server-shared'; import { assertNotNullOrUndefined } from '@openops/shared'; import axios from 'axios'; import FormData from 'form-data'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { httpAuth } from '../common/auth'; import { httpMethodDropdown } from '../common/props'; +import { validateAndRewritePublicWebhookUrl } from '../common/webhook-url-validator'; const toLowerCaseKeys = (obj: HttpHeaders) => Object.fromEntries(Object.entries(obj).map(([k, v]) => [k.toLowerCase(), v])); @@ -170,10 +171,8 @@ export const httpSendRequestAction = createAction({ assertNotNullOrUndefined(method, 'Method'); assertNotNullOrUndefined(url, 'URL'); - await validateHostAllowingPublicWebhookUrl(url); - await validateHostAllowingPublicWebhookUrl( - context.propsValue.proxy_settings?.proxy_host, - ); + const newUrl = await validateAndRewritePublicWebhookUrl(url); + await validateHost(context.propsValue.proxy_settings?.proxy_host); const headersArray = (context.auth?.headers as @@ -193,7 +192,7 @@ export const httpSendRequestAction = createAction({ const request: HttpRequest = { method, - url, + url: newUrl, headers: mergedHeaders, queryParams: (queryParams ?? {}) as QueryParams, timeout: timeout ? timeout * 1000 : 0, diff --git a/packages/blocks/http/src/lib/common/webhook-url-validator.ts b/packages/blocks/http/src/lib/common/webhook-url-validator.ts new file mode 100644 index 0000000000..a3be5fad50 --- /dev/null +++ b/packages/blocks/http/src/lib/common/webhook-url-validator.ts @@ -0,0 +1,50 @@ +import { networkUtls, validateHost } from '@openops/server-shared'; + +export async function validateAndRewritePublicWebhookUrl( + userUrl: string, +): Promise { + if (!userUrl) { + return userUrl; + } + + try { + await validateHost(userUrl); + return userUrl; + } catch (error) { + const publicUrl = await networkUtls.getPublicUrl(); + const internalUrl = new URL(networkUtls.getInternalApiUrl()); + const escapedBase = publicUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + const regex = new RegExp( + `^${escapedBase}v1/webhooks/[0-9a-zA-Z]{21}/sync$`, + ); + + if (!regex.test(userUrl)) { + throw error; + } + + const userUrlObj = new URL(userUrl); + const publicUrlObj = new URL(publicUrl); + + if (userUrlObj.origin !== publicUrlObj.origin) { + return userUrl; + } + + userUrlObj.protocol = internalUrl.protocol; + userUrlObj.hostname = internalUrl.hostname; + userUrlObj.port = internalUrl.port; + + const publicBasePath = publicUrlObj.pathname.replace(/\/$/, ''); + + if ( + publicBasePath && + publicBasePath !== '/' && + userUrlObj.pathname.startsWith(publicBasePath) + ) { + const newPath = userUrlObj.pathname.slice(publicBasePath.length); + userUrlObj.pathname = newPath.startsWith('/') ? newPath : `/${newPath}`; + } + + return userUrlObj.toString(); + } +} diff --git a/packages/blocks/http/test/webhook-url-validator.test.ts b/packages/blocks/http/test/webhook-url-validator.test.ts new file mode 100644 index 0000000000..4656221f95 --- /dev/null +++ b/packages/blocks/http/test/webhook-url-validator.test.ts @@ -0,0 +1,101 @@ +import { networkUtls, validateHost } from '@openops/server-shared'; +import { validateAndRewritePublicWebhookUrl } from '../src/lib/common/webhook-url-validator'; + +jest.mock('@openops/server-shared', () => ({ + validateHost: jest.fn(), + networkUtls: { + getPublicUrl: jest.fn(), + getInternalApiUrl: jest.fn(), + }, +})); + +describe('webhook-url-validator', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return the original URL if it is empty', async () => { + const result = await validateAndRewritePublicWebhookUrl(''); + expect(result).toBe(''); + }); + + it('should return the original URL if validateHost succeeds', async () => { + (validateHost as jest.Mock).mockResolvedValue(undefined); + const userUrl = 'https://example.com/webhook'; + const result = await validateAndRewritePublicWebhookUrl(userUrl); + expect(result).toBe(userUrl); + expect(validateHost).toHaveBeenCalledWith(userUrl); + }); + + describe('when validateHost fails', () => { + const error = new Error('Host must not be an internal address'); + const publicUrl = 'https://app.openops.com/'; + const internalApiUrl = 'http://api-service:3000/'; + + beforeEach(() => { + (validateHost as jest.Mock).mockRejectedValue(error); + (networkUtls.getPublicUrl as jest.Mock).mockResolvedValue(publicUrl); + (networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue( + internalApiUrl, + ); + }); + + it('should re-throw the error if the URL does not match the webhook regex', async () => { + const userUrl = 'https://internal.host/some-other-path'; + await expect(validateAndRewritePublicWebhookUrl(userUrl)).rejects.toThrow( + error, + ); + }); + + it('should re-throw the error if the URL matches regex but has different origin', async () => { + // In reality, it will throw because it won't pass the regex (since regex includes publicUrl) + // but let's test if we can somehow hit the "return userUrl" branch at line 30. + // If publicUrl is 'https://app.openops.com/' + // escapedBase is 'https:\/\/app\.openops\.com\/' + // regex is '^https:\/\/app\.openops\.com\/v1/webhooks/[0-9a-zA-Z]{21}/sync$' + // To pass regex, the URL MUST start with the publicUrl. + // So let's see if origin comparison could still fail? + // Maybe with case differences or port differences? + // But URL origin and regex should match. + // Let's test the main rewrite case. + }); + + it('should rewrite the URL to internal API URL when it is a valid system webhook', async () => { + const webhookId = 'abc123456789012345678'; + const userUrl = `${publicUrl}v1/webhooks/${webhookId}/sync`; + + const result = await validateAndRewritePublicWebhookUrl(userUrl); + + expect(result).toBe( + `http://api-service:3000/v1/webhooks/${webhookId}/sync`, + ); + }); + + it('should handle public URL with a base path correctly during rewrite', async () => { + const basePublicUrl = 'https://openops.com/app/'; + const internalUrl = 'http://internal:3000/'; + (networkUtls.getPublicUrl as jest.Mock).mockResolvedValue(basePublicUrl); + (networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue(internalUrl); + + const webhookId = 'abc123456789012345678'; + const userUrl = `${basePublicUrl}v1/webhooks/${webhookId}/sync`; + + const result = await validateAndRewritePublicWebhookUrl(userUrl); + + expect(result).toBe(`http://internal:3000/v1/webhooks/${webhookId}/sync`); + }); + + it('should handle public URL with trailing slash in base path correctly', async () => { + const basePublicUrl = 'https://openops.com/app/'; + const internalUrl = 'http://internal:3000/'; + (networkUtls.getPublicUrl as jest.Mock).mockResolvedValue(basePublicUrl); + (networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue(internalUrl); + + const webhookId = 'abc123456789012345678'; + const userUrl = `https://openops.com/app/v1/webhooks/${webhookId}/sync`; + + const result = await validateAndRewritePublicWebhookUrl(userUrl); + expect(result).toBe(`http://internal:3000/v1/webhooks/${webhookId}/sync`); + }); + }); +}); diff --git a/packages/blocks/http/tsconfig.spec.json b/packages/blocks/http/tsconfig.spec.json new file mode 100644 index 0000000000..69a251f328 --- /dev/null +++ b/packages/blocks/http/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/packages/server/shared/src/lib/host-validation/index.ts b/packages/server/shared/src/lib/host-validation/index.ts index aebfe5f351..9f34f1029e 100644 --- a/packages/server/shared/src/lib/host-validation/index.ts +++ b/packages/server/shared/src/lib/host-validation/index.ts @@ -64,27 +64,3 @@ export async function validateHost(host: string | undefined): Promise { const isPrivate = await isInternalHost(host); if (isPrivate) throw new Error('Host must not be an internal address'); } - -export async function validateHostAllowingPublicWebhookUrl( - url: string | undefined, -): Promise { - if (!url) { - return; - } - - try { - await validateHost(url); - } catch (error) { - const publicUrl = await networkUtls.getPublicUrl(); - - const escapedBase = publicUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - - const regex = new RegExp( - `^${escapedBase}v1/webhooks/[0-9a-zA-Z]{21}/sync$`, - ); - - if (!regex.test(url)) { - throw error; - } - } -} diff --git a/packages/server/shared/test/host-validation/index.test.ts b/packages/server/shared/test/host-validation/index.test.ts index 6b4f99f05b..aae25d838c 100644 --- a/packages/server/shared/test/host-validation/index.test.ts +++ b/packages/server/shared/test/host-validation/index.test.ts @@ -19,10 +19,7 @@ jest.mock('../../src/lib/network-utils', () => ({ networkUtls: { getPublicUrl }, })); -import { - validateHost, - validateHostAllowingPublicWebhookUrl, -} from '../../src/lib/host-validation'; +import { validateHost } from '../../src/lib/host-validation'; describe('Host Validation', () => { beforeEach(() => { @@ -128,76 +125,4 @@ describe('Host Validation', () => { 'Host must not be an internal address', ); }); - - describe('validateHostAllowingPublicWebhookUrl', () => { - test('should skip for empty url', async () => { - const url = ''; - await expect( - validateHostAllowingPublicWebhookUrl(url), - ).resolves.toBeUndefined(); - }); - - test('should allow public webhook url even if it resolves to private ip', async () => { - const publicUrl = 'https://openops.example.com/'; - const webhookUrl = - 'https://openops.example.com/v1/webhooks/123456789012345678901/sync'; - getPublicUrl.mockResolvedValue(publicUrl); - resolve4.mockResolvedValue(['127.0.0.1']); - resolve6.mockResolvedValue([]); - - await expect( - validateHostAllowingPublicWebhookUrl(webhookUrl), - ).resolves.toBeUndefined(); - }); - - test('should throw for internal host that is not the public webhook url', async () => { - const publicUrl = 'https://openops.example.com/'; - const internalUrl = - 'https://10.0.0.1/v1/webhooks/123456789012345678901/sync'; - getPublicUrl.mockResolvedValue(publicUrl); - resolve4.mockResolvedValue(['10.0.0.1']); - resolve6.mockResolvedValue([]); - - await expect( - validateHostAllowingPublicWebhookUrl(internalUrl), - ).rejects.toThrow('Host must not be an internal address'); - }); - - test('should throw for public webhook url with invalid id length', async () => { - const publicUrl = 'https://openops.example.com/'; - const webhookUrl = - 'https://openops.example.com/v1/webhooks/too-short/sync'; - getPublicUrl.mockResolvedValue(publicUrl); - resolve4.mockResolvedValue(['127.0.0.1']); - resolve6.mockResolvedValue([]); - - await expect( - validateHostAllowingPublicWebhookUrl(webhookUrl), - ).rejects.toThrow('Host must not be an internal address'); - }); - - test('should allow public host', async () => { - const publicUrl = 'https://openops.example.com/'; - const somePublicUrl = 'https://google.com'; - getPublicUrl.mockResolvedValue(publicUrl); - resolve4.mockResolvedValue(['8.8.8.8']); - resolve6.mockResolvedValue([]); - - await expect( - validateHostAllowingPublicWebhookUrl(somePublicUrl), - ).resolves.toBeUndefined(); - }); - - test('should throw error for DNS resolution failure on non-webhook URL', async () => { - const publicUrl = 'https://openops.example.com/'; - const unknownUrl = 'https://unknown.example.com'; - getPublicUrl.mockResolvedValue(publicUrl); - resolve4.mockRejectedValue(new Error('DNS resolution failed')); - resolve6.mockRejectedValue(new Error('DNS resolution failed')); - - await expect( - validateHostAllowingPublicWebhookUrl(unknownUrl), - ).rejects.toThrow('Failed to resolve host'); - }); - }); }); From cbc4283c3accd13bd4fc270c6cea8b5fbf78b08a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Thu, 9 Apr 2026 13:09:07 +0100 Subject: [PATCH 2/5] Fix lint --- packages/server/shared/src/lib/host-validation/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/server/shared/src/lib/host-validation/index.ts b/packages/server/shared/src/lib/host-validation/index.ts index 9f34f1029e..e7042c3692 100644 --- a/packages/server/shared/src/lib/host-validation/index.ts +++ b/packages/server/shared/src/lib/host-validation/index.ts @@ -1,7 +1,6 @@ import { promises as dns } from 'dns'; import ipRangeCheck from 'ip-range-check'; import { isIPv4, isIPv6 } from 'net'; -import { networkUtls } from '../network-utils'; import { SharedSystemProp, system } from '../system'; const internalV4Cidrs = [ From c34f14f54d077aa9df741e99287e4cc70984acbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Thu, 9 Apr 2026 13:10:35 +0100 Subject: [PATCH 3/5] Fix tests --- .../blocks/http/test/webhook-url-validator.test.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/blocks/http/test/webhook-url-validator.test.ts b/packages/blocks/http/test/webhook-url-validator.test.ts index 4656221f95..17a80a362f 100644 --- a/packages/blocks/http/test/webhook-url-validator.test.ts +++ b/packages/blocks/http/test/webhook-url-validator.test.ts @@ -47,19 +47,6 @@ describe('webhook-url-validator', () => { ); }); - it('should re-throw the error if the URL matches regex but has different origin', async () => { - // In reality, it will throw because it won't pass the regex (since regex includes publicUrl) - // but let's test if we can somehow hit the "return userUrl" branch at line 30. - // If publicUrl is 'https://app.openops.com/' - // escapedBase is 'https:\/\/app\.openops\.com\/' - // regex is '^https:\/\/app\.openops\.com\/v1/webhooks/[0-9a-zA-Z]{21}/sync$' - // To pass regex, the URL MUST start with the publicUrl. - // So let's see if origin comparison could still fail? - // Maybe with case differences or port differences? - // But URL origin and regex should match. - // Let's test the main rewrite case. - }); - it('should rewrite the URL to internal API URL when it is a valid system webhook', async () => { const webhookId = 'abc123456789012345678'; const userUrl = `${publicUrl}v1/webhooks/${webhookId}/sync`; From c165bd8efe2ce3616fed410e3d676087c256e911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Thu, 9 Apr 2026 13:15:27 +0100 Subject: [PATCH 4/5] Fix tests --- packages/blocks/http/src/lib/common/webhook-url-validator.ts | 1 - packages/server/shared/test/host-validation/index.test.ts | 4 ---- 2 files changed, 5 deletions(-) diff --git a/packages/blocks/http/src/lib/common/webhook-url-validator.ts b/packages/blocks/http/src/lib/common/webhook-url-validator.ts index a3be5fad50..562a19a093 100644 --- a/packages/blocks/http/src/lib/common/webhook-url-validator.ts +++ b/packages/blocks/http/src/lib/common/webhook-url-validator.ts @@ -25,7 +25,6 @@ export async function validateAndRewritePublicWebhookUrl( const userUrlObj = new URL(userUrl); const publicUrlObj = new URL(publicUrl); - if (userUrlObj.origin !== publicUrlObj.origin) { return userUrl; } diff --git a/packages/server/shared/test/host-validation/index.test.ts b/packages/server/shared/test/host-validation/index.test.ts index aae25d838c..fcd44da29a 100644 --- a/packages/server/shared/test/host-validation/index.test.ts +++ b/packages/server/shared/test/host-validation/index.test.ts @@ -1,7 +1,6 @@ const resolve4 = jest.fn(); const resolve6 = jest.fn(); const getBoolean = jest.fn().mockReturnValue(true); -const getPublicUrl = jest.fn(); jest.mock('dns', () => ({ promises: { resolve4, resolve6 } })); jest.mock('../../src/lib/system', () => ({ @@ -15,9 +14,6 @@ jest.mock('../../src/lib/system', () => ({ }, AppSystemProp: { CLIENT_REAL_IP_HEADER: 'CLIENT_REAL_IP_HEADER' }, })); -jest.mock('../../src/lib/network-utils', () => ({ - networkUtls: { getPublicUrl }, -})); import { validateHost } from '../../src/lib/host-validation'; From 1114fbae3b14f02b2e118cc0f6bda736ccfadc62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Gon=C3=A7alves?= Date: Thu, 9 Apr 2026 14:36:11 +0100 Subject: [PATCH 5/5] WIP --- .../src/lib/common/webhook-url-validator.ts | 48 +++--- .../http/test/webhook-url-validator.test.ts | 147 +++++++++++------- 2 files changed, 115 insertions(+), 80 deletions(-) diff --git a/packages/blocks/http/src/lib/common/webhook-url-validator.ts b/packages/blocks/http/src/lib/common/webhook-url-validator.ts index 562a19a093..cca6258b97 100644 --- a/packages/blocks/http/src/lib/common/webhook-url-validator.ts +++ b/packages/blocks/http/src/lib/common/webhook-url-validator.ts @@ -12,38 +12,40 @@ export async function validateAndRewritePublicWebhookUrl( return userUrl; } catch (error) { const publicUrl = await networkUtls.getPublicUrl(); - const internalUrl = new URL(networkUtls.getInternalApiUrl()); - const escapedBase = publicUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const internalApiUrl = networkUtls.getInternalApiUrl(); - const regex = new RegExp( - `^${escapedBase}v1/webhooks/[0-9a-zA-Z]{21}/sync$`, - ); - - if (!regex.test(userUrl)) { - throw error; - } - - const userUrlObj = new URL(userUrl); const publicUrlObj = new URL(publicUrl); + const internalUrlObj = new URL(internalApiUrl); + const userUrlObj = new URL(userUrl); + if (userUrlObj.origin !== publicUrlObj.origin) { - return userUrl; + throw error; } - userUrlObj.protocol = internalUrl.protocol; - userUrlObj.hostname = internalUrl.hostname; - userUrlObj.port = internalUrl.port; - - const publicBasePath = publicUrlObj.pathname.replace(/\/$/, ''); + const internalBasePath = internalUrlObj.pathname.replace(/\/$/, ''); + let relativePath = userUrlObj.pathname; if ( - publicBasePath && - publicBasePath !== '/' && - userUrlObj.pathname.startsWith(publicBasePath) + internalBasePath && + internalBasePath !== '/' && + relativePath.startsWith(internalBasePath) ) { - const newPath = userUrlObj.pathname.slice(publicBasePath.length); - userUrlObj.pathname = newPath.startsWith('/') ? newPath : `/${newPath}`; + relativePath = relativePath.slice(internalBasePath.length); + } + + if (!relativePath.startsWith('/')) { + relativePath = `/${relativePath}`; + } + + if (!/^\/v1\/webhooks\/[0-9A-Za-z]{21}\/sync$/.test(relativePath)) { + throw error; } - return userUrlObj.toString(); + const rewrittenPath = `${internalBasePath}${relativePath}`.replace( + /\/{2,}/g, + '/', + ); + + return `${internalUrlObj.origin}${rewrittenPath}`; } } diff --git a/packages/blocks/http/test/webhook-url-validator.test.ts b/packages/blocks/http/test/webhook-url-validator.test.ts index 17a80a362f..b99c7b648b 100644 --- a/packages/blocks/http/test/webhook-url-validator.test.ts +++ b/packages/blocks/http/test/webhook-url-validator.test.ts @@ -19,7 +19,7 @@ describe('webhook-url-validator', () => { expect(result).toBe(''); }); - it('should return the original URL if validateHost succeeds', async () => { + it('should return the original URL if validateHost passes', async () => { (validateHost as jest.Mock).mockResolvedValue(undefined); const userUrl = 'https://example.com/webhook'; const result = await validateAndRewritePublicWebhookUrl(userUrl); @@ -27,62 +27,95 @@ describe('webhook-url-validator', () => { expect(validateHost).toHaveBeenCalledWith(userUrl); }); - describe('when validateHost fails', () => { + it('should rewrite the URL if it matches the public URL origin and valid webhook path', async () => { const error = new Error('Host must not be an internal address'); - const publicUrl = 'https://app.openops.com/'; - const internalApiUrl = 'http://api-service:3000/'; - - beforeEach(() => { - (validateHost as jest.Mock).mockRejectedValue(error); - (networkUtls.getPublicUrl as jest.Mock).mockResolvedValue(publicUrl); - (networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue( - internalApiUrl, - ); - }); - - it('should re-throw the error if the URL does not match the webhook regex', async () => { - const userUrl = 'https://internal.host/some-other-path'; - await expect(validateAndRewritePublicWebhookUrl(userUrl)).rejects.toThrow( - error, - ); - }); - - it('should rewrite the URL to internal API URL when it is a valid system webhook', async () => { - const webhookId = 'abc123456789012345678'; - const userUrl = `${publicUrl}v1/webhooks/${webhookId}/sync`; - - const result = await validateAndRewritePublicWebhookUrl(userUrl); - - expect(result).toBe( - `http://api-service:3000/v1/webhooks/${webhookId}/sync`, - ); - }); - - it('should handle public URL with a base path correctly during rewrite', async () => { - const basePublicUrl = 'https://openops.com/app/'; - const internalUrl = 'http://internal:3000/'; - (networkUtls.getPublicUrl as jest.Mock).mockResolvedValue(basePublicUrl); - (networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue(internalUrl); - - const webhookId = 'abc123456789012345678'; - const userUrl = `${basePublicUrl}v1/webhooks/${webhookId}/sync`; - - const result = await validateAndRewritePublicWebhookUrl(userUrl); - - expect(result).toBe(`http://internal:3000/v1/webhooks/${webhookId}/sync`); - }); - - it('should handle public URL with trailing slash in base path correctly', async () => { - const basePublicUrl = 'https://openops.com/app/'; - const internalUrl = 'http://internal:3000/'; - (networkUtls.getPublicUrl as jest.Mock).mockResolvedValue(basePublicUrl); - (networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue(internalUrl); - - const webhookId = 'abc123456789012345678'; - const userUrl = `https://openops.com/app/v1/webhooks/${webhookId}/sync`; - - const result = await validateAndRewritePublicWebhookUrl(userUrl); - expect(result).toBe(`http://internal:3000/v1/webhooks/${webhookId}/sync`); - }); + (validateHost as jest.Mock).mockRejectedValue(error); + (networkUtls.getPublicUrl as jest.Mock).mockResolvedValue( + 'https://public.openops.com', + ); + (networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue( + 'http://internal-api:3000', + ); + + const userUrl = + 'https://public.openops.com/v1/webhooks/123456789012345678901/sync'; + const result = await validateAndRewritePublicWebhookUrl(userUrl); + + expect(result).toBe( + 'http://internal-api:3000/v1/webhooks/123456789012345678901/sync', + ); + }); + + it('should rewrite the URL when public URL has a base path', async () => { + const error = new Error('Host must not be an internal address'); + (validateHost as jest.Mock).mockRejectedValue(error); + (networkUtls.getPublicUrl as jest.Mock).mockResolvedValue( + 'https://openops.com/', + ); + (networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue( + 'http://internal-api:3000/api', + ); + + const userUrl = + 'https://openops.com/api/v1/webhooks/123456789012345678901/sync'; + const result = await validateAndRewritePublicWebhookUrl(userUrl); + + expect(result).toBe( + 'http://internal-api:3000/api/v1/webhooks/123456789012345678901/sync', + ); + }); + + it('should throw the original error if origin does not match public URL origin', async () => { + const error = new Error('Host must not be an internal address'); + (validateHost as jest.Mock).mockRejectedValue(error); + (networkUtls.getPublicUrl as jest.Mock).mockResolvedValue( + 'https://public.openops.com', + ); + (networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue( + 'http://internal-api:3000', + ); + + const userUrl = + 'https://other-domain.com/v1/webhooks/123456789012345678901/sync'; + + await expect(validateAndRewritePublicWebhookUrl(userUrl)).rejects.toThrow( + error, + ); + }); + + it('should throw the original error if the path does not match the webhook pattern', async () => { + const error = new Error('Host must not be an internal address'); + (validateHost as jest.Mock).mockRejectedValue(error); + (networkUtls.getPublicUrl as jest.Mock).mockResolvedValue( + 'https://public.openops.com', + ); + (networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue( + 'http://internal-api:3000', + ); + + const userUrl = 'https://public.openops.com/v1/webhooks/invalid-id/sync'; + + await expect(validateAndRewritePublicWebhookUrl(userUrl)).rejects.toThrow( + error, + ); + }); + + it('should handle multiple slashes correctly during rewrite', async () => { + const error = new Error('Host must not be an internal address'); + (validateHost as jest.Mock).mockRejectedValue(error); + (networkUtls.getPublicUrl as jest.Mock).mockResolvedValue( + 'https://public.openops.com/', + ); + (networkUtls.getInternalApiUrl as jest.Mock).mockReturnValue( + 'http://internal-api:3000/', + ); + + const userUrl = + 'https://public.openops.com/v1/webhooks/123456789012345678901/sync'; + const result = await validateAndRewritePublicWebhookUrl(userUrl); + + expect(result).toBe( + 'http://internal-api:3000/v1/webhooks/123456789012345678901/sync', + ); }); });