diff --git a/web/package-lock.json b/web/package-lock.json index b13b8eba6..939a05c49 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/jest": "^30.0.0", @@ -6792,6 +6794,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", @@ -6837,6 +6922,13 @@ "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", @@ -8706,6 +8798,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", @@ -11096,6 +11198,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": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", @@ -11170,6 +11282,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", @@ -18411,6 +18530,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 378dbf074..e017a2674 100644 --- a/web/package.json +++ b/web/package.json @@ -115,6 +115,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/jest": "^30.0.0", diff --git a/web/src/features/metrics/components/QueryKebab.spec.tsx b/web/src/features/metrics/components/QueryKebab.spec.tsx index 7b2e8b85e..aa8949f3f 100644 --- a/web/src/features/metrics/components/QueryKebab.spec.tsx +++ b/web/src/features/metrics/components/QueryKebab.spec.tsx @@ -1,11 +1,9 @@ /** @jest-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 @@ jest.mock('react-i18next', () => ({ })); const renderQueryKebab = (text?: string, onCreateAlert = jest.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 = jest.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(); }); }); 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:'; };