diff --git a/packages/core/src/__tests__/internal/fetchSettings.test.ts b/packages/core/src/__tests__/internal/fetchSettings.test.ts index a9ad87f6d..b6ec2a750 100644 --- a/packages/core/src/__tests__/internal/fetchSettings.test.ts +++ b/packages/core/src/__tests__/internal/fetchSettings.test.ts @@ -281,4 +281,158 @@ describe('internal #getSettings', () => { expect(setSettingsSpy).not.toHaveBeenCalled(); } ); + describe('getEndpointForSettings', () => { + it.each([ + ['example.com/v1/', 'https://example.com/v1/'], + ['https://example.com/v1/', 'https://example.com/v1/'], + ['http://example.com/v1/', 'http://example.com/v1/'], + ])( + 'should append projects/key/settings if proxy end with / and useSegmentEndpoint is true', + (cdnProxy, expectedBaseURL) => { + const config = { + ...clientArgs.config, + useSegmentEndpoints: true, + cdnProxy: cdnProxy, + }; + const anotherClient = new SegmentClient({ + ...clientArgs, + config, + }); + const spy = jest.spyOn( + Object.getPrototypeOf(anotherClient), + 'getEndpointForSettings' + ); + expect(anotherClient['getEndpointForSettings']()).toBe( + `${expectedBaseURL}projects/${config.writeKey}/settings` + ); + expect(spy).toHaveBeenCalled(); + } + ); + it.each([ + ['example.com/v1/projects/', 'https://example.com/v1/projects/'], + ['https://example.com/v1/projects/', 'https://example.com/v1/projects/'], + ['http://example.com/v1/projects/', 'http://example.com/v1/projects/'], + ])( + 'should append projects/writeKey/settings if proxy ends with projects/ and useSegmentEndpoint is true', + (cdnProxy, expectedBaseURL) => { + const config = { + ...clientArgs.config, + useSegmentEndpoints: true, + cdnProxy: cdnProxy, + }; + const anotherClient = new SegmentClient({ + ...clientArgs, + config, + }); + + const spy = jest.spyOn( + Object.getPrototypeOf(anotherClient), + 'getEndpointForSettings' + ); + expect(anotherClient['getEndpointForSettings']()).toBe( + `${expectedBaseURL}projects/${config.writeKey}/settings` + ); + expect(spy).toHaveBeenCalled(); + } + ); + it.each([ + ['example.com/v1/projects', 'https://example.com/v1/projects'], + ['https://example.com/v1/projects', 'https://example.com/v1/projects'], + ['http://example.com/v1/projects', 'http://example.com/v1/projects'], + ])( + 'should append /projects/writeKey/settings if proxy ends with /projects and useSegmentEndpoint is true', + (cdnProxy, expectedBaseURL) => { + const config = { + ...clientArgs.config, + useSegmentEndpoints: true, + cdnProxy: cdnProxy, + }; + const anotherClient = new SegmentClient({ + ...clientArgs, + config, + }); + + const spy = jest.spyOn( + Object.getPrototypeOf(anotherClient), + 'getEndpointForSettings' + ); + expect(anotherClient['getEndpointForSettings']()).toBe( + `${expectedBaseURL}/projects/${config.writeKey}/settings` + ); + expect(spy).toHaveBeenCalled(); + } + ); + it.each([ + ['example.com/v1?params=xx'], + ['https://example.com/v1?params=xx'], + ['http://example.com/v1?params=xx'], + ])( + 'should throw an error if proxy comes with query params and useSegmentEndpoint is true', + (cdnProxy) => { + const config = { + ...clientArgs.config, + useSegmentEndpoints: true, + cdnProxy: cdnProxy, + }; + const anotherClient = new SegmentClient({ + ...clientArgs, + config, + }); + + const spy = jest.spyOn( + Object.getPrototypeOf(anotherClient), + 'getEndpointForSettings' + ); + // Expect the private method to throw an error + expect(anotherClient['getEndpointForSettings']()).toBe( + `${settingsCDN}/${config.writeKey}/settings` + ); + expect(spy).toHaveBeenCalled(); + } + ); + it.each([ + ['example.com/v1/', false], + ['example.com/v1/projects/', false], + ['example.com/v1/projects', false], + ['example.com/v1?params=xx', false], + ])( + 'should always return identical result if proxy is provided and useSegmentEndpoints is false', + (cdnProxy, useSegmentEndpoints) => { + const config = { + ...clientArgs.config, + useSegmentEndpoints: useSegmentEndpoints, + cdnProxy: cdnProxy, + }; + const anotherClient = new SegmentClient({ + ...clientArgs, + config, + }); + const spy = jest.spyOn( + Object.getPrototypeOf(anotherClient), + 'getEndpointForSettings' + ); + const expected = `https://${cdnProxy}`; + expect(anotherClient['getEndpointForSettings']()).toBe(expected); + expect(spy).toHaveBeenCalled(); + } + ); + it('No cdn proxy provided, should return default settings CDN', () => { + const config = { + ...clientArgs.config, + useSegmentEndpoints: true, // No matter in this case + }; + const anotherClient = new SegmentClient({ + ...clientArgs, + config, + }); + const spy = jest.spyOn( + Object.getPrototypeOf(anotherClient), + 'getEndpointForSettings' + ); + expect(anotherClient['getEndpointForSettings']()).toBe( + `${settingsCDN}/${config.writeKey}/settings` + ); + expect(spy).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/core/src/__tests__/util.test.ts b/packages/core/src/__tests__/util.test.ts index 3d2a9a5ea..9125f4426 100644 --- a/packages/core/src/__tests__/util.test.ts +++ b/packages/core/src/__tests__/util.test.ts @@ -172,7 +172,7 @@ describe('getURL function', () => { }); it('should return the root URL when the path is empty', () => { - expect(getURL('www.example.com', '')).toBe('https://www.example.com/'); + expect(getURL('www.example.com', '')).toBe('https://www.example.com'); }); it('should handle query parameters correctly in the URL path', () => { @@ -188,13 +188,13 @@ describe('getURL function', () => { }); // Negative Test Cases - it('should handle empty host gracefully', () => { - expect(getURL('', '/home')).toBe('https:///home'); + it('should throw an error for empty host', () => { + expect(() => getURL('', '/home')).toThrow('Invalid URL has been passed'); }); - it('should handle invalid characters in the host', () => { - expect(getURL('invalid host.com', '/path')).toBe( - 'https://invalid host.com/path' + it('should throw an error for invalid characters in the host', () => { + expect(() => getURL('invalid host.com', '/path')).toThrow( + 'Invalid URL has been passed' ); }); }); diff --git a/packages/core/src/analytics.ts b/packages/core/src/analytics.ts index c32ca84cf..9b88c5468 100644 --- a/packages/core/src/analytics.ts +++ b/packages/core/src/analytics.ts @@ -320,22 +320,37 @@ export class SegmentClient { return map; } - - async fetchSettings() { - const settingsPrefix: string = this.config.cdnProxy ?? settingsCDN; + private getEndpointForSettings(): string { + let settingsPrefix = ''; + let settingsEndpoint = ''; const hasProxy = !!(this.config?.cdnProxy ?? ''); const useSegmentEndpoints = Boolean(this.config?.useSegmentEndpoints); - let settingsEndpoint = ''; + if (hasProxy) { + settingsPrefix = this.config.cdnProxy ?? ''; if (useSegmentEndpoints) { - settingsEndpoint = `/projects/${this.config.writeKey}/settings`; - } else { - settingsEndpoint = ''; + const isCdnProxyEndsWithSlash = settingsPrefix.endsWith('/'); + settingsEndpoint = isCdnProxyEndsWithSlash + ? `projects/${this.config.writeKey}/settings` + : `/projects/${this.config.writeKey}/settings`; } } else { + settingsPrefix = settingsCDN; settingsEndpoint = `/${this.config.writeKey}/settings`; } - const settingsURL = getURL(settingsPrefix, settingsEndpoint); + try { + return getURL(settingsPrefix, settingsEndpoint); + } catch (error) { + console.error( + 'Error in getEndpointForSettings:', + `fallback to ${settingsCDN}/${this.config.writeKey}/settings` + ); + return `${settingsCDN}/${this.config.writeKey}/settings`; + } + } + + async fetchSettings() { + const settingsURL = this.getEndpointForSettings(); try { const res = await fetch(settingsURL, { headers: { diff --git a/packages/core/src/plugins/SegmentDestination.ts b/packages/core/src/plugins/SegmentDestination.ts index ba2353ccd..cc7e911e6 100644 --- a/packages/core/src/plugins/SegmentDestination.ts +++ b/packages/core/src/plugins/SegmentDestination.ts @@ -92,17 +92,24 @@ export class SegmentDestination extends DestinationPlugin { const config = this.analytics?.getConfig(); const hasProxy = !!(config?.proxy ?? ''); const useSegmentEndpoints = Boolean(config?.useSegmentEndpoints); - + let baseURL = ''; let endpoint = ''; - if (hasProxy) { - endpoint = useSegmentEndpoints ? '/b' : ''; + //baseURL is always config?.proxy if hasProxy + baseURL = config?.proxy ?? ''; + if (useSegmentEndpoints) { + const isProxyEndsWithSlash = baseURL.endsWith('/'); + endpoint = isProxyEndsWithSlash ? 'b' : '/b'; + } } else { - endpoint = '/b'; // If no proxy, always append '/b' + baseURL = this.apiHost ?? defaultApiHost; + } + try { + return getURL(baseURL, endpoint); + } catch (error) { + console.error('Error in getEndpoint:', `fallback to ${defaultApiHost}`); + return defaultApiHost; } - - const baseURL = config?.proxy ?? this.apiHost ?? defaultApiHost; - return getURL(baseURL, endpoint); } configure(analytics: SegmentClient): void { super.configure(analytics); @@ -128,7 +135,7 @@ export class SegmentDestination extends DestinationPlugin { segmentSettings?.apiHost !== null ) { //assign the api host from segment settings (domain/v1) - this.apiHost = segmentSettings.apiHost; + this.apiHost = `https://${segmentSettings.apiHost}/b`; } this.settingsResolve(); } diff --git a/packages/core/src/plugins/__tests__/SegmentDestination.test.ts b/packages/core/src/plugins/__tests__/SegmentDestination.test.ts index 0116c9dab..885097fd9 100644 --- a/packages/core/src/plugins/__tests__/SegmentDestination.test.ts +++ b/packages/core/src/plugins/__tests__/SegmentDestination.test.ts @@ -320,14 +320,14 @@ describe('SegmentDestination', () => { expect(sendEventsSpy).toHaveBeenCalledTimes(2); expect(sendEventsSpy).toHaveBeenCalledWith({ - url: getURL(defaultApiHost, '/b'), + url: getURL(defaultApiHost, ''), // default api already appended with '/b' writeKey: '123-456', events: events.slice(0, 2).map((e) => ({ ...e, })), }); expect(sendEventsSpy).toHaveBeenCalledWith({ - url: getURL(defaultApiHost, '/b'), + url: getURL(defaultApiHost, ''), // default api already appended with '/b' writeKey: '123-456', events: events.slice(2, 4).map((e) => ({ ...e, @@ -396,16 +396,11 @@ describe('SegmentDestination', () => { expectedUrl = getURL(customEndpoint, '/b'); } else { expectedUrl = getURL(customEndpoint, ''); - console.log('expected URL---->', expectedUrl); } } else { expectedUrl = getURL('events.eu1.segmentapis.com', '/b'); } - // let expectedUrl = hasProxy - // ? getURL(customEndpoint, useSegmentEndpoints ? '/b' : '') - // : getURL('events.eu1.segmentapis.com', '/b'); - await plugin.flush(); expect(sendEventsSpy).toHaveBeenCalledTimes(1); @@ -419,4 +414,136 @@ describe('SegmentDestination', () => { } ); }); + describe('getEndpoint', () => { + it.each([ + ['example.com/v1/', 'https://example.com/v1/'], + ['https://example.com/v1/', 'https://example.com/v1/'], + ['http://example.com/v1/', 'http://example.com/v1/'], + ])( + 'should append b if proxy end with / and useSegmentEndpoint is true', + (proxy, expectedBaseURL) => { + const plugin = new SegmentDestination(); + const config = { + ...clientArgs.config, + useSegmentEndpoints: true, + proxy: proxy, + }; + plugin.analytics = new SegmentClient({ + ...clientArgs, + config, + }); + const spy = jest.spyOn(Object.getPrototypeOf(plugin), 'getEndpoint'); + expect(plugin['getEndpoint']()).toBe(`${expectedBaseURL}b`); + expect(spy).toHaveBeenCalled(); + } + ); + it.each([ + ['example.com/v1/b/', 'https://example.com/v1/b/'], + ['https://example.com/v1/b/', 'https://example.com/v1/b/'], + ['http://example.com/v1/b/', 'http://example.com/v1/b/'], + ])( + 'should append b if proxy ends with b/ and useSegmentEndpoint is true', + (proxy, expectedBaseURL) => { + const plugin = new SegmentDestination(); + const config = { + ...clientArgs.config, + useSegmentEndpoints: true, + proxy: proxy, + }; + plugin.analytics = new SegmentClient({ + ...clientArgs, + config, + }); + + const spy = jest.spyOn(Object.getPrototypeOf(plugin), 'getEndpoint'); + expect(plugin['getEndpoint']()).toBe(`${expectedBaseURL}b`); + expect(spy).toHaveBeenCalled(); + } + ); + it.each([ + ['example.com/v1/b', 'https://example.com/v1/b'], + ['https://example.com/v1/b', 'https://example.com/v1/b'], + ['http://example.com/v1/b', 'http://example.com/v1/b'], + ])( + 'should append /b if proxy ends with /b and useSegmentEndpoint is true', + (proxy, expectedBaseURL) => { + const plugin = new SegmentDestination(); + const config = { + ...clientArgs.config, + useSegmentEndpoints: true, + proxy: proxy, + }; + plugin.analytics = new SegmentClient({ + ...clientArgs, + config, + }); + + const spy = jest.spyOn(Object.getPrototypeOf(plugin), 'getEndpoint'); + expect(plugin['getEndpoint']()).toBe(`${expectedBaseURL}/b`); + expect(spy).toHaveBeenCalled(); + } + ); + it.each([ + ['example.com/v1?params=xx'], + ['https://example.com/v1?params=xx'], + ['http://example.com/v1?params=xx'], + ])( + 'should throw an error if proxy comes with query params and useSegmentEndpoint is true', + (proxy) => { + const plugin = new SegmentDestination(); + const config = { + ...clientArgs.config, + useSegmentEndpoints: true, + proxy: proxy, + }; + plugin.analytics = new SegmentClient({ + ...clientArgs, + config, + }); + + const spy = jest.spyOn(Object.getPrototypeOf(plugin), 'getEndpoint'); + // Expect the private method to throw an error + expect(plugin['getEndpoint']()).toBe(defaultApiHost); + expect(spy).toHaveBeenCalled(); + } + ); + it.each([ + ['example.com/v1/', false], + ['example.com/b/', false], + ['example.com/b', false], + ['example.com/v1?params=xx', false], + ])( + 'should always return identical result if proxy is provided and useSegmentEndpoints is false', + (proxy, useSegmentEndpoints) => { + const plugin = new SegmentDestination(); + const config = { + ...clientArgs.config, + useSegmentEndpoints: useSegmentEndpoints, + proxy: proxy, + }; + plugin.analytics = new SegmentClient({ + ...clientArgs, + config, + }); + const spy = jest.spyOn(Object.getPrototypeOf(plugin), 'getEndpoint'); + const expected = `https://${proxy}`; + expect(plugin['getEndpoint']()).toBe(expected); + expect(spy).toHaveBeenCalled(); + } + ); + it('No proxy provided, should return default API host', () => { + const plugin = new SegmentDestination(); + const config = { + ...clientArgs.config, + useSegmentEndpoints: true, // No matter in this case + }; + plugin.analytics = new SegmentClient({ + ...clientArgs, + config, + }); + const spy = jest.spyOn(Object.getPrototypeOf(plugin), 'getEndpoint'); + expect(plugin['getEndpoint']()).toBe(defaultApiHost); + expect(spy).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts index 47360005f..91237ff2b 100644 --- a/packages/core/src/util.ts +++ b/packages/core/src/util.ts @@ -259,12 +259,30 @@ export const createPromise = ( }; export function getURL(host: string, path: string) { - if (path === '') { - path = '/'; // Ensure a trailing slash if path is empty - } if (!host.startsWith('https://') && !host.startsWith('http://')) { host = 'https://' + host; } const s = `${host}${path}`; + if (!validateURL(s)) { + console.error('Invalid URL has been passed'); + console.log(`Invalid Url passed is ${s}`); + throw new Error('Invalid URL has been passed'); + } + return s; } + +export function validateURL(url: string): boolean { + const urlRegex = new RegExp( + '^(?:https?:\\/\\/)' + // Protocol (http or https) + '(?:\\S+(?::\\S*)?@)?' + // Optional user:pass@ + '(?:(localhost|\\d{1,3}(?:\\.\\d{1,3}){3})|' + // Localhost or IP address + '(?:(?!-)[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*(?:\\.[a-zA-Z]{2,})))' + // Domain validation (supports hyphens) + '(?::\\d{2,5})?' + // Optional port + '(\\/[^\\s?#]*)?' + // Path (allows `/projects/yup/settings`) + '(\\?[a-zA-Z0-9_.-]+=[a-zA-Z0-9_.-]+(&[a-zA-Z0-9_.-]+=[a-zA-Z0-9_.-]+)*)?' + // Query params + '(#[^\\s]*)?$', // Fragment (optional) + 'i' // Case-insensitive + ); + return urlRegex.test(url); +}