From 3d51404330953e194e073f3b295f2f27e9cca8fe Mon Sep 17 00:00:00 2001 From: Alex Luck Date: Mon, 3 Nov 2025 10:52:31 -0800 Subject: [PATCH 1/2] add noopener and noreferring to window.open --- .../sharelink/OpenInSiftButton.test.tsx | 6 ++-- src/components/sharelink/OpenInSiftButton.tsx | 2 +- .../sharelink/generateLinkFromQuery.ts | 33 +++++++++++-------- .../sharelink/getFrontendHostnameDefaults.ts | 10 +++++- src/resources.hooks.ts | 2 +- 5 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/components/sharelink/OpenInSiftButton.test.tsx b/src/components/sharelink/OpenInSiftButton.test.tsx index e818b50..c6afc15 100644 --- a/src/components/sharelink/OpenInSiftButton.test.tsx +++ b/src/components/sharelink/OpenInSiftButton.test.tsx @@ -64,7 +64,7 @@ describe('OpenInSiftButton', () => { const button = screen.getByRole('button', { name: 'Open in Sift' }); fireEvent.click(button); - expect(openSpy).toHaveBeenCalledWith('https://sift.example.com/explorer', '_blank'); + expect(openSpy).toHaveBeenCalledWith('https://sift.example.com/explorer', '_blank', 'noopener,noreferrer'); openSpy.mockRestore(); }); @@ -96,7 +96,7 @@ describe('OpenInSiftButton', () => { const button = screen.getByRole('button', { name: 'Open in Sift' }); fireEvent.click(button); - expect(openSpy).toHaveBeenCalledWith('https://sift.example.com/explorer', '_blank'); + expect(openSpy).toHaveBeenCalledWith('https://sift.example.com/explorer', '_blank', 'noopener,noreferrer'); openSpy.mockRestore(); }); @@ -148,7 +148,7 @@ describe('OpenInSiftButton', () => { const button = screen.getByRole('button', { name: 'Open in Sift' }); fireEvent.click(button); - expect(openSpy).toHaveBeenCalledWith('https://sift.example.com/explorer', '_blank'); + expect(openSpy).toHaveBeenCalledWith('https://sift.example.com/explorer', '_blank', 'noopener,noreferrer'); openSpy.mockRestore(); }); diff --git a/src/components/sharelink/OpenInSiftButton.tsx b/src/components/sharelink/OpenInSiftButton.tsx index 79d1ce9..4b6b2a0 100644 --- a/src/components/sharelink/OpenInSiftButton.tsx +++ b/src/components/sharelink/OpenInSiftButton.tsx @@ -16,7 +16,7 @@ interface SharelinkMenuItemProps { } function openLink(link: string) { - window.open(link, '_blank'); + window.open(link, '_blank', 'noopener,noreferrer'); } async function copyToClipboard(value: string) { diff --git a/src/components/sharelink/generateLinkFromQuery.ts b/src/components/sharelink/generateLinkFromQuery.ts index 263a133..50d2cfb 100644 --- a/src/components/sharelink/generateLinkFromQuery.ts +++ b/src/components/sharelink/generateLinkFromQuery.ts @@ -115,11 +115,8 @@ function normaliseBasePath(basePath: string): string { if (!basePath.startsWith('/')) { return `/${basePath}`; } - return basePath; -} - -function stripTrailingSlash(input: string): string { - return input.endsWith('/') ? input.slice(0, -1) : input; + // Remove trailing slash for consistency + return basePath.endsWith('/') ? basePath.slice(0, -1) : basePath; } function setIfPresent(hash: URLSearchParams, key: string, value: string | undefined | null) { @@ -181,20 +178,30 @@ function createExplorerLink(params: ExplorerLinkParams): string { const hashString = hashParams.toString(); - let url = basePath; - if (hashString) { - url += `#${hashString}`; - } - + // If origin is provided, use URL constructor for proper URL building if (params.origin) { - url = `${stripTrailingSlash(params.origin)}${url}`; + const url = new URL(basePath, params.origin); + if (hashString) { + url.hash = hashString; + } + return url.toString(); } - return url; + // Return relative URL + return hashString ? `${basePath}#${hashString}` : basePath; } export function generateLinkFromQuery(hostname: string, items: SharelinkItems, timeRange?: SharelinkTimeRange) { - const origin = hostname.startsWith('http://') || hostname.startsWith('https://') ? hostname : `https://${hostname}`; + // Normalize hostname to a valid origin URL + let origin: string; + try { + // If hostname is already a valid URL, use it directly + const testUrl = new URL(hostname); + origin = testUrl.origin; + } catch { + // If not a valid URL, assume it's a hostname and prepend https:// + origin = `https://${hostname}`; + } const channelIds = items.channelIds; const channelKeys = channelIds.map((_, index) => `channel-key-${index + 1}`); const legendChannels: LegendConfigPayload['channels'] = {}; diff --git a/src/components/sharelink/getFrontendHostnameDefaults.ts b/src/components/sharelink/getFrontendHostnameDefaults.ts index 48a9286..cab8b9b 100644 --- a/src/components/sharelink/getFrontendHostnameDefaults.ts +++ b/src/components/sharelink/getFrontendHostnameDefaults.ts @@ -7,7 +7,15 @@ export function getFrontendHostnameDefaults(apiBaseUrl: string): string | null { return null; } - const cleanUrl = apiBaseUrl.replace(/^https?:\/\//, '').trim(); + // Use URL constructor to properly parse the URL and extract the host + let cleanUrl: string; + try { + const url = new URL(apiBaseUrl); + cleanUrl = url.host; // host includes port if present + } catch { + // If not a valid URL, assume it's already a hostname and use as-is + cleanUrl = apiBaseUrl.trim(); + } switch (cleanUrl) { case 'api.siftstack.com': diff --git a/src/resources.hooks.ts b/src/resources.hooks.ts index 4517f86..09c555a 100644 --- a/src/resources.hooks.ts +++ b/src/resources.hooks.ts @@ -11,7 +11,7 @@ import { } from './types'; import { getTemplateSrv, getAppEvents, RefreshEvent } from '@grafana/runtime'; import { TypedVariableModel, BusEventWithPayload } from '@grafana/data'; -import { CELUtil, replaceTemplateVariablesInQuery } from './utils'; +import { CELUtil } from './utils'; import { debounce } from 'lodash'; import leven from 'leven'; From 5f5434b232e55f18c0f9246bfa168b11a27037b0 Mon Sep 17 00:00:00 2001 From: Alex Luck Date: Mon, 3 Nov 2025 11:19:40 -0800 Subject: [PATCH 2/2] add more tests --- .../sharelink/OpenInSiftButton.test.tsx | 52 ++++ src/components/sharelink/OpenInSiftButton.tsx | 17 +- .../sharelink/generateLinkFromQuery.test.ts | 250 ++++++++++++++++++ .../sharelink/generateLinkFromQuery.ts | 37 ++- .../getFrontendHostnameDefaults.test.ts | 176 ++++++++++++ .../sharelink/getFrontendHostnameDefaults.ts | 8 +- 6 files changed, 529 insertions(+), 11 deletions(-) create mode 100644 src/components/sharelink/generateLinkFromQuery.test.ts create mode 100644 src/components/sharelink/getFrontendHostnameDefaults.test.ts diff --git a/src/components/sharelink/OpenInSiftButton.test.tsx b/src/components/sharelink/OpenInSiftButton.test.tsx index c6afc15..2812d37 100644 --- a/src/components/sharelink/OpenInSiftButton.test.tsx +++ b/src/components/sharelink/OpenInSiftButton.test.tsx @@ -210,4 +210,56 @@ describe('OpenInSiftButton', () => { expect(screen.getByRole('menuitem', { name: /Open Link/i })).toBeDisabled(); }); }); + + it('handles errors from generateLinkFromQuery gracefully', async () => { + const items = { + channelIds: ['channel-1'], + assetIds: ['asset-1'], + runIds: ['run-1'], + calculatedChannels: [], + }; + + generateLinkFromQueryMock.mockImplementation(() => { + throw new Error('Test error'); + }); + + render(); + + expect(generateLinkFromQueryMock).toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith('Failed to generate share link:', expect.any(Error)); + + const menuButton = screen.getByRole('button', { name: 'Open in Sift' }); + expect(menuButton).toHaveAttribute('aria-disabled', 'true'); + + fireEvent.contextMenu(menuButton); + + await waitFor(() => { + expect(screen.getByRole('menuitem', { name: /Open Link/i })).toBeDisabled(); + }); + }); + + it('handles null hostname from getFrontendHostnameDefaults', async () => { + const items = { + channelIds: ['channel-1'], + assetIds: ['asset-1'], + runIds: ['run-1'], + calculatedChannels: [], + }; + + getFrontendHostnameDefaultsMock.mockReturnValue(null); + + render(); + + expect(getFrontendHostnameDefaultsMock).toHaveBeenCalledWith('https://unknown-api.example.com'); + expect(generateLinkFromQueryMock).not.toHaveBeenCalled(); + + const menuButton = screen.getByRole('button', { name: 'Open in Sift' }); + expect(menuButton).toHaveAttribute('aria-disabled', 'true'); + + fireEvent.contextMenu(menuButton); + + await waitFor(() => { + expect(screen.getByRole('menuitem', { name: /Open Link/i })).toBeDisabled(); + }); + }); }); diff --git a/src/components/sharelink/OpenInSiftButton.tsx b/src/components/sharelink/OpenInSiftButton.tsx index 4b6b2a0..f1c9026 100644 --- a/src/components/sharelink/OpenInSiftButton.tsx +++ b/src/components/sharelink/OpenInSiftButton.tsx @@ -63,10 +63,19 @@ export const OpenInSiftButton = ({ className, items, apiBaseUrl, frontendUrl, ti disabledReason: 'Configure the Sift API REST URL to enable share links', }; } - return { - shareLink: generateLinkFromQuery(hostname, items, timeRange), - disabledReason: undefined, - }; + + try { + return { + shareLink: generateLinkFromQuery(hostname, items, timeRange), + disabledReason: undefined, + }; + } catch (error) { + console.error('Failed to generate share link:', error); + return { + shareLink: null, + disabledReason: 'Failed to generate share link', + }; + } }, [apiBaseUrl, frontendUrl, items, timeRange]); return ( diff --git a/src/components/sharelink/generateLinkFromQuery.test.ts b/src/components/sharelink/generateLinkFromQuery.test.ts new file mode 100644 index 0000000..18cffaf --- /dev/null +++ b/src/components/sharelink/generateLinkFromQuery.test.ts @@ -0,0 +1,250 @@ +import { generateLinkFromQuery } from './generateLinkFromQuery'; +import type { SharelinkItems, SharelinkTimeRange } from '../../types'; + +describe('generateLinkFromQuery', () => { + const mockItems: SharelinkItems = { + channelIds: ['channel-1', 'channel-2'], + assetIds: ['asset-1'], + runIds: ['run-1'], + calculatedChannels: [], + }; + + describe('URL construction with hostname normalization', () => { + it('should handle hostname without protocol and prepend https://', () => { + const result = generateLinkFromQuery('app.siftstack.com', mockItems); + + expect(result).toMatch(/^https:\/\/app\.siftstack\.com\/explorer#/); + }); + + it('should handle hostname with https:// protocol', () => { + const result = generateLinkFromQuery('https://app.siftstack.com', mockItems); + + expect(result).toMatch(/^https:\/\/app\.siftstack\.com\/explorer#/); + }); + + it('should handle hostname with http:// protocol', () => { + const result = generateLinkFromQuery('http://localhost:3000', mockItems); + + expect(result).toMatch(/^http:\/\/localhost:3000\/explorer#/); + }); + + it('should handle hostname with port', () => { + const result = generateLinkFromQuery('localhost:8080', mockItems); + + expect(result).toMatch(/^https:\/\/localhost:8080\/explorer#/); + }); + + it('should extract origin from full URL with path', () => { + const result = generateLinkFromQuery('https://app.siftstack.com/some/path', mockItems); + + // Should use only the origin, not the path + expect(result).toMatch(/^https:\/\/app\.siftstack\.com\/explorer#/); + expect(result).not.toContain('/some/path'); + }); + + it('should handle URL with query parameters by using only origin', () => { + const result = generateLinkFromQuery('https://app.siftstack.com?foo=bar', mockItems); + + expect(result).toMatch(/^https:\/\/app\.siftstack\.com\/explorer#/); + expect(result).not.toContain('?foo=bar'); + }); + + it('should handle URL with trailing slash', () => { + const result = generateLinkFromQuery('https://app.siftstack.com/', mockItems); + + expect(result).toMatch(/^https:\/\/app\.siftstack\.com\/explorer#/); + }); + }); + + describe('hash parameters encoding', () => { + it('should include assets in hash', () => { + const items: SharelinkItems = { + channelIds: ['channel-1'], + assetIds: ['asset-1', 'asset-2'], + runIds: [], + calculatedChannels: [], + }; + + const result = generateLinkFromQuery('app.siftstack.com', items); + + expect(result).toContain('assets=asset-1%2Casset-2'); + }); + + it('should include runs in hash', () => { + const items: SharelinkItems = { + channelIds: ['channel-1'], + assetIds: [], + runIds: ['run-1', 'run-2'], + calculatedChannels: [], + }; + + const result = generateLinkFromQuery('app.siftstack.com', items); + + expect(result).toContain('runs=run-1%2Crun-2'); + }); + + it('should base64 encode legend configuration', () => { + const result = generateLinkFromQuery('app.siftstack.com', mockItems); + + // Should contain base64 encoded legend + expect(result).toContain('legend='); + // Base64 strings typically contain these characters + expect(result).toMatch(/legend=[A-Za-z0-9+/=%]+/); + }); + + it('should include time range in legend when provided', () => { + const timeRange: SharelinkTimeRange = { + from: '2024-01-01T00:00:00Z', + to: '2024-01-02T00:00:00Z', + }; + + const result = generateLinkFromQuery('app.siftstack.com', mockItems, timeRange); + + // The time range should be encoded in the legend parameter + expect(result).toContain('legend='); + }); + + it('should handle calculated channels', () => { + const itemsWithCalc: SharelinkItems = { + channelIds: ['channel-1'], + assetIds: ['asset-1'], + runIds: ['run-1'], + calculatedChannels: [ + { + name: 'Calculated Channel', + sourceChannels: ['channel-1', 'channel-2'], + expression: '$1 + $2', + expressionDataType: 'DOUBLE', + }, + ], + }; + + const result = generateLinkFromQuery('app.siftstack.com', itemsWithCalc); + + // Should still generate a valid URL with legend + expect(result).toMatch(/^https:\/\/app\.siftstack\.com\/explorer#/); + expect(result).toContain('legend='); + }); + + it('should handle multiple calculated channels', () => { + const itemsWithMultipleCalc: SharelinkItems = { + channelIds: ['channel-1'], + assetIds: ['asset-1'], + runIds: ['run-1'], + calculatedChannels: [ + { + name: 'Calc 1', + sourceChannels: ['channel-1'], + expression: '$1 * 2', + expressionDataType: 'DOUBLE', + }, + { + name: 'Calc 2', + sourceChannels: ['channel-1', 'channel-2'], + expression: '$1 + $2', + expressionDataType: 'DOUBLE', + }, + ], + }; + + const result = generateLinkFromQuery('app.siftstack.com', itemsWithMultipleCalc); + + expect(result).toMatch(/^https:\/\/app\.siftstack\.com\/explorer#/); + expect(result).toContain('legend='); + }); + }); + + describe('edge cases', () => { + it('should handle empty asset and run arrays', () => { + const items: SharelinkItems = { + channelIds: ['channel-1'], + assetIds: [], + runIds: [], + calculatedChannels: [], + }; + + const result = generateLinkFromQuery('app.siftstack.com', items); + + expect(result).toMatch(/^https:\/\/app\.siftstack\.com\/explorer#/); + // Should not include assets or runs parameters + expect(result).not.toContain('assets='); + expect(result).not.toContain('runs='); + }); + + it('should handle undefined asset and run arrays', () => { + const items: SharelinkItems = { + channelIds: ['channel-1'], + assetIds: undefined, + runIds: undefined, + calculatedChannels: [], + }; + + const result = generateLinkFromQuery('app.siftstack.com', items); + + expect(result).toMatch(/^https:\/\/app\.siftstack\.com\/explorer#/); + expect(result).not.toContain('assets='); + expect(result).not.toContain('runs='); + }); + + it('should handle special characters in channel IDs', () => { + const items: SharelinkItems = { + channelIds: ['channel-with-dash', 'channel_with_underscore'], + assetIds: ['asset-1'], + runIds: ['run-1'], + calculatedChannels: [], + }; + + const result = generateLinkFromQuery('app.siftstack.com', items); + + expect(result).toMatch(/^https:\/\/app\.siftstack\.com\/explorer#/); + expect(result).toContain('legend='); + }); + + it('should handle IPv4 addresses', () => { + const result = generateLinkFromQuery('192.168.1.1:8080', mockItems); + + expect(result).toMatch(/^https:\/\/192\.168\.1\.1:8080\/explorer#/); + }); + + it('should handle localhost variations', () => { + const result1 = generateLinkFromQuery('localhost', mockItems); + expect(result1).toMatch(/^https:\/\/localhost\/explorer#/); + + const result2 = generateLinkFromQuery('http://localhost:3000', mockItems); + expect(result2).toMatch(/^http:\/\/localhost:3000\/explorer#/); + }); + }); + + describe('URL structure validation', () => { + it('should generate valid URL structure', () => { + const result = generateLinkFromQuery('app.siftstack.com', mockItems); + + // Should be a valid URL + expect(() => new URL(result)).not.toThrow(); + + const url = new URL(result); + expect(url.protocol).toMatch(/^https?:$/); + expect(url.pathname).toBe('/explorer'); + expect(url.hash).toMatch(/^#.+/); + }); + + it('should properly encode hash parameters', () => { + const result = generateLinkFromQuery('app.siftstack.com', mockItems); + const url = new URL(result); + + // Hash should be parseable as URLSearchParams (without the # prefix) + const hashParams = new URLSearchParams(url.hash.slice(1)); + + expect(hashParams.has('legend')).toBe(true); + expect(hashParams.has('assets')).toBe(true); + expect(hashParams.has('runs')).toBe(true); + }); + + it('should not double-encode URL components', () => { + const result = generateLinkFromQuery('app.siftstack.com', mockItems); + + // Should not contain double-encoded characters like %25 (encoded %) + expect(result).not.toContain('%25'); + }); + }); +}); diff --git a/src/components/sharelink/generateLinkFromQuery.ts b/src/components/sharelink/generateLinkFromQuery.ts index 50d2cfb..4559aaa 100644 --- a/src/components/sharelink/generateLinkFromQuery.ts +++ b/src/components/sharelink/generateLinkFromQuery.ts @@ -180,11 +180,18 @@ function createExplorerLink(params: ExplorerLinkParams): string { // If origin is provided, use URL constructor for proper URL building if (params.origin) { - const url = new URL(basePath, params.origin); - if (hashString) { - url.hash = hashString; + try { + const url = new URL(basePath, params.origin); + if (hashString) { + url.hash = hashString; + } + return url.toString(); + } catch { + // If URL construction fails, fall back to string concatenation + // This shouldn't happen if origin is properly validated + const fullUrl = `${params.origin}${basePath}`; + return hashString ? `${fullUrl}#${hashString}` : fullUrl; } - return url.toString(); } // Return relative URL @@ -192,15 +199,33 @@ function createExplorerLink(params: ExplorerLinkParams): string { } export function generateLinkFromQuery(hostname: string, items: SharelinkItems, timeRange?: SharelinkTimeRange) { + // Guard against null/undefined hostname + if (!hostname) { + throw new Error('hostname is required'); + } + // Normalize hostname to a valid origin URL let origin: string; try { // If hostname is already a valid URL, use it directly const testUrl = new URL(hostname); - origin = testUrl.origin; + // Check if origin is valid (not null or 'null') + if (testUrl.origin && testUrl.origin !== 'null') { + origin = testUrl.origin; + } else { + // URL parsed but origin is invalid, treat as hostname + throw new Error('Invalid origin'); + } } catch { // If not a valid URL, assume it's a hostname and prepend https:// - origin = `https://${hostname}`; + const withProtocol = `https://${hostname}`; + try { + const validatedUrl = new URL(withProtocol); + origin = validatedUrl.origin; + } catch { + // If still invalid, fall back to the string (shouldn't happen with valid hostnames) + origin = withProtocol; + } } const channelIds = items.channelIds; const channelKeys = channelIds.map((_, index) => `channel-key-${index + 1}`); diff --git a/src/components/sharelink/getFrontendHostnameDefaults.test.ts b/src/components/sharelink/getFrontendHostnameDefaults.test.ts new file mode 100644 index 0000000..6bc5262 --- /dev/null +++ b/src/components/sharelink/getFrontendHostnameDefaults.test.ts @@ -0,0 +1,176 @@ +import { getFrontendHostnameDefaults } from './getFrontendHostnameDefaults'; + +describe('getFrontendHostnameDefaults', () => { + describe('known API endpoints', () => { + it('should map api.siftstack.com to app.siftstack.com', () => { + expect(getFrontendHostnameDefaults('api.siftstack.com')).toBe('app.siftstack.com'); + }); + + it('should map api.siftstack.com with https:// to app.siftstack.com', () => { + expect(getFrontendHostnameDefaults('https://api.siftstack.com')).toBe('app.siftstack.com'); + }); + + it('should map api.siftstack.com with http:// to app.siftstack.com', () => { + expect(getFrontendHostnameDefaults('http://api.siftstack.com')).toBe('app.siftstack.com'); + }); + + it('should map gov.api.siftstack.com to gov.siftstack.com', () => { + expect(getFrontendHostnameDefaults('gov.api.siftstack.com')).toBe('gov.siftstack.com'); + }); + + it('should map gov.api.siftstack.com with https:// to gov.siftstack.com', () => { + expect(getFrontendHostnameDefaults('https://gov.api.siftstack.com')).toBe('gov.siftstack.com'); + }); + + it('should map localhost:8080 to http://localhost:3000', () => { + expect(getFrontendHostnameDefaults('localhost:8080')).toBe('http://localhost:3000'); + }); + + it('should map localhost:8080 with http:// to http://localhost:3000', () => { + expect(getFrontendHostnameDefaults('http://localhost:8080')).toBe('http://localhost:3000'); + }); + + it('should map host.docker.internal:8080 to http://localhost:3000', () => { + expect(getFrontendHostnameDefaults('host.docker.internal:8080')).toBe('http://localhost:3000'); + }); + + it('should map host.docker.internal:8080 with http:// to http://localhost:3000', () => { + expect(getFrontendHostnameDefaults('http://host.docker.internal:8080')).toBe('http://localhost:3000'); + }); + }); + + describe('URL parsing with protocol', () => { + it('should extract host from URL with https protocol', () => { + expect(getFrontendHostnameDefaults('https://api.siftstack.com')).toBe('app.siftstack.com'); + }); + + it('should extract host from URL with http protocol', () => { + expect(getFrontendHostnameDefaults('http://api.siftstack.com')).toBe('app.siftstack.com'); + }); + + it('should extract host from URL with path', () => { + expect(getFrontendHostnameDefaults('https://api.siftstack.com/some/path')).toBe('app.siftstack.com'); + }); + + it('should extract host from URL with query parameters', () => { + expect(getFrontendHostnameDefaults('https://api.siftstack.com?foo=bar')).toBe('app.siftstack.com'); + }); + + it('should extract host from URL with port', () => { + expect(getFrontendHostnameDefaults('https://api.siftstack.com:8443')).toBe(null); + }); + + it('should handle URL with trailing slash', () => { + expect(getFrontendHostnameDefaults('https://api.siftstack.com/')).toBe('app.siftstack.com'); + }); + }); + + describe('hostname without protocol', () => { + it('should handle plain hostname', () => { + expect(getFrontendHostnameDefaults('api.siftstack.com')).toBe('app.siftstack.com'); + }); + + it('should handle hostname with port', () => { + expect(getFrontendHostnameDefaults('localhost:8080')).toBe('http://localhost:3000'); + }); + + it('should handle hostname with subdomain', () => { + expect(getFrontendHostnameDefaults('gov.api.siftstack.com')).toBe('gov.siftstack.com'); + }); + }); + + describe('unknown endpoints', () => { + it('should return null for unknown hostname', () => { + expect(getFrontendHostnameDefaults('unknown.example.com')).toBe(null); + }); + + it('should return null for unknown hostname with https://', () => { + expect(getFrontendHostnameDefaults('https://unknown.example.com')).toBe(null); + }); + + it('should return null for unknown hostname with port', () => { + expect(getFrontendHostnameDefaults('unknown.example.com:8080')).toBe(null); + }); + + it('should return null for unknown localhost port', () => { + expect(getFrontendHostnameDefaults('localhost:9999')).toBe(null); + }); + }); + + describe('edge cases', () => { + it('should return null for empty string', () => { + expect(getFrontendHostnameDefaults('')).toBe(null); + }); + + it('should return null for whitespace only', () => { + expect(getFrontendHostnameDefaults(' ')).toBe(null); + }); + + it('should handle URL with extra whitespace', () => { + expect(getFrontendHostnameDefaults(' https://api.siftstack.com ')).toBe('app.siftstack.com'); + }); + + it('should handle hostname with extra whitespace', () => { + expect(getFrontendHostnameDefaults(' api.siftstack.com ')).toBe('app.siftstack.com'); + }); + + it('should handle IPv4 address', () => { + expect(getFrontendHostnameDefaults('192.168.1.1:8080')).toBe(null); + }); + + it('should handle IPv4 address with protocol', () => { + expect(getFrontendHostnameDefaults('http://192.168.1.1:8080')).toBe(null); + }); + }); + + describe('URL constructor behavior', () => { + it('should properly extract host from full URL', () => { + const result = getFrontendHostnameDefaults('https://api.siftstack.com:443/api/v1'); + // Port 443 is default for https, so it should be stripped by URL.host + expect(result).toBe('app.siftstack.com'); + }); + + it('should preserve non-default ports in host extraction', () => { + const result = getFrontendHostnameDefaults('http://localhost:8080/api'); + expect(result).toBe('http://localhost:3000'); + }); + + it('should handle URLs with fragments', () => { + const result = getFrontendHostnameDefaults('https://api.siftstack.com#section'); + expect(result).toBe('app.siftstack.com'); + }); + + it('should handle URLs with authentication', () => { + const result = getFrontendHostnameDefaults('https://user:pass@api.siftstack.com'); + expect(result).toBe('app.siftstack.com'); + }); + }); + + describe('protocol variations', () => { + it('should handle uppercase protocol', () => { + expect(getFrontendHostnameDefaults('HTTPS://api.siftstack.com')).toBe('app.siftstack.com'); + }); + + it('should handle mixed case protocol', () => { + expect(getFrontendHostnameDefaults('HtTpS://api.siftstack.com')).toBe('app.siftstack.com'); + }); + + it('should handle http protocol for known endpoint', () => { + expect(getFrontendHostnameDefaults('http://api.siftstack.com')).toBe('app.siftstack.com'); + }); + }); + + describe('special localhost cases', () => { + it('should map localhost:8080 regardless of protocol', () => { + expect(getFrontendHostnameDefaults('localhost:8080')).toBe('http://localhost:3000'); + expect(getFrontendHostnameDefaults('http://localhost:8080')).toBe('http://localhost:3000'); + expect(getFrontendHostnameDefaults('https://localhost:8080')).toBe('http://localhost:3000'); + }); + + it('should map host.docker.internal:8080 regardless of protocol', () => { + expect(getFrontendHostnameDefaults('host.docker.internal:8080')).toBe('http://localhost:3000'); + expect(getFrontendHostnameDefaults('http://host.docker.internal:8080')).toBe('http://localhost:3000'); + expect(getFrontendHostnameDefaults('https://host.docker.internal:8080')).toBe('http://localhost:3000'); + }); + }); +}); diff --git a/src/components/sharelink/getFrontendHostnameDefaults.ts b/src/components/sharelink/getFrontendHostnameDefaults.ts index cab8b9b..ca713fe 100644 --- a/src/components/sharelink/getFrontendHostnameDefaults.ts +++ b/src/components/sharelink/getFrontendHostnameDefaults.ts @@ -11,7 +11,13 @@ export function getFrontendHostnameDefaults(apiBaseUrl: string): string | null { let cleanUrl: string; try { const url = new URL(apiBaseUrl); - cleanUrl = url.host; // host includes port if present + // Check if we got a valid absolute URL (origin should not be null) + if (url.origin && url.origin !== 'null' && url.host) { + cleanUrl = url.host; // host includes port if present + } else { + // URL parsed as relative, treat as hostname + throw new Error('Relative URL'); + } } catch { // If not a valid URL, assume it's already a hostname and use as-is cleanUrl = apiBaseUrl.trim();