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:';
};