From 40b2c0be0175a0bfdcdec97da24eb9b2f3b26e0f Mon Sep 17 00:00:00 2001 From: Daniel Chromik Date: Wed, 9 Sep 2026 13:31:02 +0200 Subject: [PATCH 1/4] feat: added URL validation to alert details --- .../alerts/pages/AlertRulesDetailsPage.tsx | 4 +-- .../alerts/pages/AlertsDetailsPage.tsx | 3 +- web/src/shared/utils/utils.spec.ts | 29 ++++++++++++++++++- web/src/shared/utils/utils.ts | 15 ++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/web/src/features/alerts/pages/AlertRulesDetailsPage.tsx b/web/src/features/alerts/pages/AlertRulesDetailsPage.tsx index 424023087..923ac8a5b 100644 --- a/web/src/features/alerts/pages/AlertRulesDetailsPage.tsx +++ b/web/src/features/alerts/pages/AlertRulesDetailsPage.tsx @@ -68,7 +68,7 @@ import { getQueryBrowserUrl, usePerspective, } from '@/shared/hooks/usePerspective'; -import { alertDescription, RuleResource } from '@/shared/utils/utils'; +import { alertDescription, getSafeExternalURL, RuleResource } from '@/shared/utils/utils'; // Renders Prometheus template text and highlights any {{ ... }} tags that it contains const PrometheusTemplate = ({ text }: { text: string }) => ( @@ -169,7 +169,7 @@ const AlertRulesDetailsPage_: FC = () => { return `${nameLabel}{${_.map(otherLabels, (v, k) => `${k}="${v}"`).join(',')}}`; }; - const runbookURL = rule?.annotations?.runbook_url; + const runbookURL = getSafeExternalURL(rule?.annotations?.runbook_url); return ( <> diff --git a/web/src/features/alerts/pages/AlertsDetailsPage.tsx b/web/src/features/alerts/pages/AlertsDetailsPage.tsx index 45ab9687d..09176553f 100644 --- a/web/src/features/alerts/pages/AlertsDetailsPage.tsx +++ b/web/src/features/alerts/pages/AlertsDetailsPage.tsx @@ -87,6 +87,7 @@ import { usePerspective, } from '@/shared/hooks/usePerspective'; import { MonitoringState } from '@/shared/store/store'; +import { getSafeExternalURL } from '@/shared/utils/utils'; import { AlertResource, alertState, RuleResource } from '@/shared/utils/utils'; const AlertsDetailsPage_: FC = () => { @@ -121,7 +122,7 @@ const AlertsDetailsPage_: FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps const labels: PrometheusLabels = useMemo(() => alert?.labels, [labelsMemoKey]); - const runbookURL = alert?.annotations?.runbook_url; + const runbookURL = getSafeExternalURL(alert?.annotations?.runbook_url); const sourceId = rule?.sourceId; diff --git a/web/src/shared/utils/utils.spec.ts b/web/src/shared/utils/utils.spec.ts index b611367d4..ab980853e 100644 --- a/web/src/shared/utils/utils.spec.ts +++ b/web/src/shared/utils/utils.spec.ts @@ -1,6 +1,6 @@ import { AlertSeverity, AlertStates, Rule } from '@openshift-console/dynamic-plugin-sdk'; -import { alertingRuleStateSort, severitySort } from '@/shared/utils/utils'; +import { alertingRuleStateSort, getSafeExternalURL, severitySort } from '@/shared/utils/utils'; const makeRule = (alerts: { state: AlertStates }[]): Rule => ({ alerts }) as unknown as Rule; @@ -102,3 +102,30 @@ describe('severitySort', () => { expect(severities).toEqual(['critical', 'warning', 'info', 'none']); }); }); + +describe('getSafeExternalURL', () => { + it('returns undefined when no URL is provided', () => { + expect(getSafeExternalURL()).toBeUndefined(); + }); + + it.each([ + 'https://runbooks.example.com/alert', + 'http://runbooks.example.com/alert?severity=high', + ])('returns a valid HTTP(S) URL unchanged: %s', (url) => { + expect(getSafeExternalURL(url)).toBe(url); + }); + + it.each([ + 'javascript:alert(1)', + 'JaVaScRiPt:alert(1)', + 'data:text/html,', + 'vbscript:msgbox(1)', + 'mailto:security@example.com', + '/runbooks/alert', + '//runbooks.example.com/alert', + '\tjavascript:alert(1)', + 'not a URL', + ])('rejects an unsafe or invalid URL: %s', (url) => { + expect(getSafeExternalURL(url)).toBeUndefined(); + }); +}); diff --git a/web/src/shared/utils/utils.ts b/web/src/shared/utils/utils.ts index 73ed612b4..ef8b8c98d 100644 --- a/web/src/shared/utils/utils.ts +++ b/web/src/shared/utils/utils.ts @@ -165,6 +165,21 @@ export const targetSource = (target: Target): AlertSource => export const isTimeoutError = (err: Error): boolean => err.name === 'TimeoutError' || err.message.includes('timed out'); +/** + * Returns an externally supplied URL only when it is an absolute HTTP(S) URL. + * + * Do not normalize the value before returning it: callers display this value to + * users, and the browser will perform the same URL parsing when navigating. + */ +export const getSafeExternalURL = (value?: string): string | undefined => { + if (!value || !URL.canParse(value)) { + return undefined; + } + + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:' ? value : undefined; +}; + /** * This function is used to get the parameters needed to break a long time period down into smaller * chunks which won't timeout From ad85c389cbfbc8294f853865fafc9ff7c75e644b Mon Sep 17 00:00:00 2001 From: Daniel Chromik Date: Wed, 9 Sep 2026 15:54:35 +0200 Subject: [PATCH 2/4] refactor: restructured logic, cleaned up dead code --- web/package-lock.json | 174 ++++++++++++++++++ web/package.json | 2 + .../alerts/pages/AlertRulesDetailsPage.tsx | 4 +- .../alerts/pages/AlertsDetailsPage.tsx | 3 +- web/src/shared/console/utils/Link.spec.tsx | 45 +++++ web/src/shared/console/utils/Link.tsx | 66 +++---- web/src/shared/utils/utils.spec.ts | 29 +-- web/src/shared/utils/utils.ts | 15 -- 8 files changed, 259 insertions(+), 79 deletions(-) create mode 100644 web/src/shared/console/utils/Link.spec.tsx diff --git a/web/package-lock.json b/web/package-lock.json index 0b765897e..2376c6d0e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -69,6 +69,8 @@ "@openshift-console/dynamic-plugin-sdk-webpack": "4.22.0", "@swc/core": "^1.15.3", "@swc/helpers": "0.5.23", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.3", "@types/ajv": "^0.0.5", "@types/classnames": "^2.2.7", "@types/js-yaml": "^4.0.9", @@ -5658,6 +5660,89 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -5703,6 +5788,58 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -7597,6 +7734,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -9812,6 +9959,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -9872,6 +10029,13 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-converter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", @@ -15240,6 +15404,16 @@ "node": ">=10" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz", diff --git a/web/package.json b/web/package.json index 4048b3e42..47015ec24 100644 --- a/web/package.json +++ b/web/package.json @@ -117,6 +117,8 @@ "@openshift-console/dynamic-plugin-sdk-webpack": "4.22.0", "@swc/core": "^1.15.3", "@swc/helpers": "0.5.23", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.3", "@types/ajv": "^0.0.5", "@types/classnames": "^2.2.7", "@types/js-yaml": "^4.0.9", diff --git a/web/src/features/alerts/pages/AlertRulesDetailsPage.tsx b/web/src/features/alerts/pages/AlertRulesDetailsPage.tsx index 923ac8a5b..424023087 100644 --- a/web/src/features/alerts/pages/AlertRulesDetailsPage.tsx +++ b/web/src/features/alerts/pages/AlertRulesDetailsPage.tsx @@ -68,7 +68,7 @@ import { getQueryBrowserUrl, usePerspective, } from '@/shared/hooks/usePerspective'; -import { alertDescription, getSafeExternalURL, RuleResource } from '@/shared/utils/utils'; +import { alertDescription, RuleResource } from '@/shared/utils/utils'; // Renders Prometheus template text and highlights any {{ ... }} tags that it contains const PrometheusTemplate = ({ text }: { text: string }) => ( @@ -169,7 +169,7 @@ const AlertRulesDetailsPage_: FC = () => { return `${nameLabel}{${_.map(otherLabels, (v, k) => `${k}="${v}"`).join(',')}}`; }; - const runbookURL = getSafeExternalURL(rule?.annotations?.runbook_url); + const runbookURL = rule?.annotations?.runbook_url; return ( <> diff --git a/web/src/features/alerts/pages/AlertsDetailsPage.tsx b/web/src/features/alerts/pages/AlertsDetailsPage.tsx index 09176553f..45ab9687d 100644 --- a/web/src/features/alerts/pages/AlertsDetailsPage.tsx +++ b/web/src/features/alerts/pages/AlertsDetailsPage.tsx @@ -87,7 +87,6 @@ import { usePerspective, } from '@/shared/hooks/usePerspective'; import { MonitoringState } from '@/shared/store/store'; -import { getSafeExternalURL } from '@/shared/utils/utils'; import { AlertResource, alertState, RuleResource } from '@/shared/utils/utils'; const AlertsDetailsPage_: FC = () => { @@ -122,7 +121,7 @@ const AlertsDetailsPage_: FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps const labels: PrometheusLabels = useMemo(() => alert?.labels, [labelsMemoKey]); - const runbookURL = getSafeExternalURL(alert?.annotations?.runbook_url); + const runbookURL = alert?.annotations?.runbook_url; const sourceId = rule?.sourceId; diff --git a/web/src/shared/console/utils/Link.spec.tsx b/web/src/shared/console/utils/Link.spec.tsx new file mode 100644 index 000000000..22a3be310 --- /dev/null +++ b/web/src/shared/console/utils/Link.spec.tsx @@ -0,0 +1,45 @@ +/** @jest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; + +import { ExternalLink, LinkifyExternal } from '@/shared/console/utils/Link'; + +describe('ExternalLink', () => { + it.each([ + 'https://runbooks.example.com/alert', + 'http://runbooks.example.com/alert?severity=high', + ])('renders an absolute HTTP(S) URL as a link: %s', (href) => { + render(); + + expect(screen.getByRole('link', { name: href }).getAttribute('href')).toBe(href); + }); + + it.each([ + 'javascript:alert(1)', + 'JaVaScRiPt:alert(1)', + 'data:text/html,', + 'vbscript:msgbox(1)', + 'mailto:security@example.com', + '/runbooks/alert', + '//runbooks.example.com/alert', + '\tjavascript:alert(1)', + 'not a URL', + ])('renders an unsafe or invalid URL as text: %s', (href) => { + const { container } = render(); + + expect(container.textContent).toBe(href); + expect(screen.queryByRole('link')).toBeNull(); + }); +}); + +describe('LinkifyExternal', () => { + it('turns URLs in its children into protected external links', () => { + const href = 'https://runbooks.example.com/alert'; + render({href}); + + const link = screen.getByRole('link', { name: href }); + expect(link.getAttribute('href')).toBe(href); + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')).toBe('noopener noreferrer'); + }); +}); diff --git a/web/src/shared/console/utils/Link.tsx b/web/src/shared/console/utils/Link.tsx index e9a5c512d..edb8c8e51 100644 --- a/web/src/shared/console/utils/Link.tsx +++ b/web/src/shared/console/utils/Link.tsx @@ -1,36 +1,32 @@ import { Button, Icon } from '@patternfly/react-core'; import { ExternalLinkAltIcon } from '@patternfly/react-icons'; import Linkify from 'linkify-react'; -import type { FC, PropsWithChildren, ReactNode } from 'react'; +import type { FC, ReactNode } from 'react'; -export const ExternalLink: FC> = ({ - children, - href, - text, - additionalClassName = '', - dataTestID, - stopPropagation, -}) => ( - -); +export const ExternalLink: FC = ({ href, text }) => { + if (!isSafeExternalURL(href)) { + return <>{text}; + } + + return ( + + ); +}; // Open links in a new window and set noopener/noreferrer. export const LinkifyExternal: FC<{ children: ReactNode }> = ({ children }) => ( @@ -41,7 +37,13 @@ LinkifyExternal.displayName = 'LinkifyExternal'; type ExternalLinkProps = { href: string; text?: ReactNode; - additionalClassName?: string; - dataTestID?: string; - stopPropagation?: boolean; +}; + +const isSafeExternalURL = (value: string): value is string => { + if (!URL.canParse(value)) { + return false; + } + + const { protocol } = new URL(value); + return protocol === 'http:' || protocol === 'https:'; }; diff --git a/web/src/shared/utils/utils.spec.ts b/web/src/shared/utils/utils.spec.ts index ab980853e..b611367d4 100644 --- a/web/src/shared/utils/utils.spec.ts +++ b/web/src/shared/utils/utils.spec.ts @@ -1,6 +1,6 @@ import { AlertSeverity, AlertStates, Rule } from '@openshift-console/dynamic-plugin-sdk'; -import { alertingRuleStateSort, getSafeExternalURL, severitySort } from '@/shared/utils/utils'; +import { alertingRuleStateSort, severitySort } from '@/shared/utils/utils'; const makeRule = (alerts: { state: AlertStates }[]): Rule => ({ alerts }) as unknown as Rule; @@ -102,30 +102,3 @@ describe('severitySort', () => { expect(severities).toEqual(['critical', 'warning', 'info', 'none']); }); }); - -describe('getSafeExternalURL', () => { - it('returns undefined when no URL is provided', () => { - expect(getSafeExternalURL()).toBeUndefined(); - }); - - it.each([ - 'https://runbooks.example.com/alert', - 'http://runbooks.example.com/alert?severity=high', - ])('returns a valid HTTP(S) URL unchanged: %s', (url) => { - expect(getSafeExternalURL(url)).toBe(url); - }); - - it.each([ - 'javascript:alert(1)', - 'JaVaScRiPt:alert(1)', - 'data:text/html,', - 'vbscript:msgbox(1)', - 'mailto:security@example.com', - '/runbooks/alert', - '//runbooks.example.com/alert', - '\tjavascript:alert(1)', - 'not a URL', - ])('rejects an unsafe or invalid URL: %s', (url) => { - expect(getSafeExternalURL(url)).toBeUndefined(); - }); -}); diff --git a/web/src/shared/utils/utils.ts b/web/src/shared/utils/utils.ts index ef8b8c98d..73ed612b4 100644 --- a/web/src/shared/utils/utils.ts +++ b/web/src/shared/utils/utils.ts @@ -165,21 +165,6 @@ export const targetSource = (target: Target): AlertSource => export const isTimeoutError = (err: Error): boolean => err.name === 'TimeoutError' || err.message.includes('timed out'); -/** - * Returns an externally supplied URL only when it is an absolute HTTP(S) URL. - * - * Do not normalize the value before returning it: callers display this value to - * users, and the browser will perform the same URL parsing when navigating. - */ -export const getSafeExternalURL = (value?: string): string | undefined => { - if (!value || !URL.canParse(value)) { - return undefined; - } - - const url = new URL(value); - return url.protocol === 'http:' || url.protocol === 'https:' ? value : undefined; -}; - /** * This function is used to get the parameters needed to break a long time period down into smaller * chunks which won't timeout From 118926f218b53f14eb6dc46f7d3ebb928c66d040 Mon Sep 17 00:00:00 2001 From: Daniel Chromik Date: Wed, 9 Sep 2026 16:00:22 +0200 Subject: [PATCH 3/4] refactor: query kebab tests switched to RTL --- .../metrics/components/QueryKebab.spec.tsx | 75 ++++++------------- 1 file changed, 24 insertions(+), 51 deletions(-) diff --git a/web/src/features/metrics/components/QueryKebab.spec.tsx b/web/src/features/metrics/components/QueryKebab.spec.tsx index a5ff0b59b..a3898fe90 100644 --- a/web/src/features/metrics/components/QueryKebab.spec.tsx +++ b/web/src/features/metrics/components/QueryKebab.spec.tsx @@ -1,11 +1,9 @@ /** @vitest-environment jsdom */ -import { act } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; import type { ButtonHTMLAttributes, PropsWithChildren, ReactNode } from 'react'; -import { createRoot } from 'react-dom/client'; import { QueryKebab } from '@/features/metrics/components/QueryKebab'; -import { DataTestIDs } from '@/shared/constants/data-test'; type DropdownProps = PropsWithChildren<{ isOpen: boolean; @@ -46,66 +44,41 @@ vi.mock('react-i18next', () => ({ })); const renderQueryKebab = (text?: string, onCreateAlert = vi.fn()) => { - const container = document.createElement('div'); - document.body.append(container); - const root = createRoot(container); - - act(() => { - root.render( - , - ); - }); + render( + , + ); - act(() => { - container.querySelector('[data-test="kebab-dropdown-button"]')?.click(); - }); + fireEvent.click(screen.getByRole('button', { name: 'toggle menu' })); - return { - cleanup: () => { - act(() => root.unmount()); - container.remove(); - }, - onCreateAlert, - }; + return onCreateAlert; }; -const getCreateAlertItem = () => - document.querySelector( - `[data-test="${DataTestIDs.MetricsPageCreateAlertRuleDropdownItem}"]`, - ); - describe('QueryKebab', () => { - Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - - afterEach(() => document.body.replaceChildren()); - it.each([undefined, ' '])('disables Create alert when the query is empty', (text) => { - const { cleanup } = renderQueryKebab(text); - const createAlertItem = getCreateAlertItem(); + renderQueryKebab(text); + const createAlertItem = screen.getByRole('button', { name: 'Create alert' }); - expect(createAlertItem?.getAttribute('aria-disabled')).toBe('true'); - cleanup(); + expect(createAlertItem.getAttribute('aria-disabled')).toBe('true'); }); it('enables Create alert and invokes its action when the query has text', () => { const onCreateAlert = vi.fn(); - const { cleanup } = renderQueryKebab('up', onCreateAlert); - const createAlertItem = getCreateAlertItem(); + renderQueryKebab('up', onCreateAlert); + const createAlertItem = screen.getByRole('button', { name: 'Create alert' }); - expect(createAlertItem?.getAttribute('aria-disabled')).not.toBe('true'); - act(() => createAlertItem?.click()); + expect(createAlertItem.getAttribute('aria-disabled')).not.toBe('true'); + fireEvent.click(createAlertItem); expect(onCreateAlert).toHaveBeenCalledTimes(1); - cleanup(); }); }); From cb24cf070692bc37ba891f10f2bebe4d5af45a3a Mon Sep 17 00:00:00 2001 From: Daniel Chromik Date: Mon, 14 Sep 2026 11:59:29 +0200 Subject: [PATCH 4/4] chore: pinned testing library versions --- web/package-lock.json | 58 +--------------------- web/package.json | 4 +- web/src/shared/console/utils/Link.spec.tsx | 2 +- 3 files changed, 5 insertions(+), 59 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 2376c6d0e..89589220e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -69,8 +69,8 @@ "@openshift-console/dynamic-plugin-sdk-webpack": "4.22.0", "@swc/core": "^1.15.3", "@swc/helpers": "0.5.23", - "@testing-library/dom": "^10.4.1", - "@testing-library/react": "^16.3.3", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.3", "@types/ajv": "^0.0.5", "@types/classnames": "^2.2.7", "@types/js-yaml": "^4.0.9", @@ -5795,51 +5795,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -9668,9 +9623,6 @@ "peer": true, "engines": { "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" } }, "node_modules/data-urls/node_modules/tr46": { @@ -12536,9 +12488,6 @@ "peer": true, "engines": { "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" } }, "node_modules/html-escaper": { @@ -14333,9 +14282,6 @@ "peer": true, "engines": { "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" } }, "node_modules/jsdom/node_modules/entities": { diff --git a/web/package.json b/web/package.json index 47015ec24..9dff5bfbe 100644 --- a/web/package.json +++ b/web/package.json @@ -117,8 +117,8 @@ "@openshift-console/dynamic-plugin-sdk-webpack": "4.22.0", "@swc/core": "^1.15.3", "@swc/helpers": "0.5.23", - "@testing-library/dom": "^10.4.1", - "@testing-library/react": "^16.3.3", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.3", "@types/ajv": "^0.0.5", "@types/classnames": "^2.2.7", "@types/js-yaml": "^4.0.9", diff --git a/web/src/shared/console/utils/Link.spec.tsx b/web/src/shared/console/utils/Link.spec.tsx index 22a3be310..e87e96129 100644 --- a/web/src/shared/console/utils/Link.spec.tsx +++ b/web/src/shared/console/utils/Link.spec.tsx @@ -1,4 +1,4 @@ -/** @jest-environment jsdom */ +/** @vitest-environment jsdom */ import { render, screen } from '@testing-library/react';