diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurl.isinternalurl.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.isinternalurl.md new file mode 100644 index 000000000000..85f2cefc1f26 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.isinternalurl.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [IExternalUrl](./kibana-plugin-core-public.iexternalurl.md) > [isInternalUrl](./kibana-plugin-core-public.iexternalurl.isinternalurl.md) + +## IExternalUrl.isInternalUrl() method + +Determines if the provided URL is an internal url. + +**Signature:** + +```typescript +isInternalUrl(relativeOrAbsoluteUrl: string): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| relativeOrAbsoluteUrl | string | | + +**Returns:** + +boolean + diff --git a/docs/development/core/public/kibana-plugin-core-public.iexternalurl.md b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.md index 09c32f68cd6f..757080090a04 100644 --- a/docs/development/core/public/kibana-plugin-core-public.iexternalurl.md +++ b/docs/development/core/public/kibana-plugin-core-public.iexternalurl.md @@ -16,5 +16,6 @@ export interface IExternalUrl | Method | Description | | --- | --- | +| [isInternalUrl(relativeOrAbsoluteUrl)](./kibana-plugin-core-public.iexternalurl.isinternalurl.md) | Determines if the provided URL is an internal url. | | [validateUrl(relativeOrAbsoluteUrl)](./kibana-plugin-core-public.iexternalurl.validateurl.md) |

Determines if the provided URL is a valid location to send users. Validation is based on the configured allow list in kibana.yml.

If the URL is valid, then a URL will be returned. Otherwise, this will return null.

| diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanarequest._inspect.custom_.md b/docs/development/core/server/kibana-plugin-core-server.kibanarequest._inspect.custom_.md new file mode 100644 index 000000000000..e57ef2b88f24 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.kibanarequest._inspect.custom_.md @@ -0,0 +1,28 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md) > [\[inspect.custom\]](./kibana-plugin-core-server.kibanarequest._inspect.custom_.md) + +## KibanaRequest.\[inspect.custom\]() method + +**Signature:** + +```typescript +[inspect.custom](): { + id: string; + uuid: string; + url: string; + isSystemRequest: boolean; + auth: { + isAuthenticated: boolean; + }; + route: Readonly<{ + path: string; + method: RecursiveReadonly; + options: RecursiveReadonly>; + }>; + }; +``` +**Returns:** + +{ id: string; uuid: string; url: string; isSystemRequest: boolean; auth: { isAuthenticated: boolean; }; route: Readonly<{ path: string; method: RecursiveReadonly<Method>; options: RecursiveReadonly<KibanaRequestRouteOptions<Method>>; }>; } + diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanarequest.tojson.md b/docs/development/core/server/kibana-plugin-core-server.kibanarequest.tojson.md new file mode 100644 index 000000000000..22d08274ef78 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.kibanarequest.tojson.md @@ -0,0 +1,28 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md) > [toJSON](./kibana-plugin-core-server.kibanarequest.tojson.md) + +## KibanaRequest.toJSON() method + +**Signature:** + +```typescript +toJSON(): { + id: string; + uuid: string; + url: string; + isSystemRequest: boolean; + auth: { + isAuthenticated: boolean; + }; + route: Readonly<{ + path: string; + method: RecursiveReadonly; + options: RecursiveReadonly>; + }>; + }; +``` +**Returns:** + +{ id: string; uuid: string; url: string; isSystemRequest: boolean; auth: { isAuthenticated: boolean; }; route: Readonly<{ path: string; method: RecursiveReadonly<Method>; options: RecursiveReadonly<KibanaRequestRouteOptions<Method>>; }>; } + diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanarequest.tostring.md b/docs/development/core/server/kibana-plugin-core-server.kibanarequest.tostring.md new file mode 100644 index 000000000000..f7ef9011c408 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.kibanarequest.tostring.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md) > [toString](./kibana-plugin-core-server.kibanarequest.tostring.md) + +## KibanaRequest.toString() method + +**Signature:** + +```typescript +toString(): string; +``` +**Returns:** + +string + diff --git a/src/core/public/http/external_url_service.test.ts b/src/core/public/http/external_url_service.test.ts index ee757c504676..4ce3709ff636 100644 --- a/src/core/public/http/external_url_service.test.ts +++ b/src/core/public/http/external_url_service.test.ts @@ -73,6 +73,23 @@ const internalRequestScenarios = [ ]; describe('External Url Service', () => { + describe('#isInternalUrl', () => { + const { setup } = setupService({ + location: new URL('https://example.com/app/management?q=1&bar=false#some-hash'), + serverBasePath: '', + policy: [], + }); + + it('internal request', () => { + expect(setup.isInternalUrl('/')).toBeTruthy(); + expect(setup.isInternalUrl('https://example.com/')).toBeTruthy(); + }); + + it('external request', () => { + expect(setup.isInternalUrl('https://elastic.co/')).toBeFalsy(); + }); + }); + describe('#validateUrl', () => { describe('internal requests with a server base path', () => { const serverBasePath = '/base-path'; diff --git a/src/core/public/http/external_url_service.ts b/src/core/public/http/external_url_service.ts index 166e167b3b99..0fb1c85d4825 100644 --- a/src/core/public/http/external_url_service.ts +++ b/src/core/public/http/external_url_service.ts @@ -50,20 +50,33 @@ function normalizeProtocol(protocol: string) { return protocol.endsWith(':') ? protocol.slice(0, -1).toLowerCase() : protocol.toLowerCase(); } +const createIsInternalUrlValidation = ( + location: Pick, + serverBasePath: string +) => { + return function isInternallUrl(next: string) { + const base = new URL(location.href); + const url = new URL(next, base); + + return ( + url.origin === base.origin && + (!serverBasePath || url.pathname.startsWith(`${serverBasePath}/`)) + ); + }; +}; + const createExternalUrlValidation = ( rules: IExternalUrlPolicy[], location: Pick, serverBasePath: string ) => { + const isInternalUrl = createIsInternalUrlValidation(location, serverBasePath); + return function validateExternalUrl(next: string) { const base = new URL(location.href); const url = new URL(next, base); - const isInternalURL = - url.origin === base.origin && - (!serverBasePath || url.pathname.startsWith(`${serverBasePath}/`)); - - if (isInternalURL) { + if (isInternalUrl(next)) { return url; } @@ -90,6 +103,7 @@ export class ExternalUrlService implements CoreService { const { policy } = injectedMetadata.getExternalUrlConfig(); return { + isInternalUrl: createIsInternalUrlValidation(location, serverBasePath), validateUrl: createExternalUrlValidation(policy, location, serverBasePath), }; } diff --git a/src/core/public/http/http_service.mock.ts b/src/core/public/http/http_service.mock.ts index fff99d84a76a..4bfecd7ed74d 100644 --- a/src/core/public/http/http_service.mock.ts +++ b/src/core/public/http/http_service.mock.ts @@ -37,6 +37,7 @@ const createServiceMock = ({ }, externalUrl: { validateUrl: jest.fn(), + isInternalUrl: jest.fn(), }, addLoadingCountSource: jest.fn(), getLoadingCount$: jest.fn().mockReturnValue(new BehaviorSubject(0)), diff --git a/src/core/public/http/types.ts b/src/core/public/http/types.ts index 3eb718b318f8..71df131161c5 100644 --- a/src/core/public/http/types.ts +++ b/src/core/public/http/types.ts @@ -110,6 +110,13 @@ export interface IBasePath { * @public */ export interface IExternalUrl { + /** + * Determines if the provided URL is an internal url. + * + * @param relativeOrAbsoluteUrl + */ + isInternalUrl(relativeOrAbsoluteUrl: string): boolean; + /** * Determines if the provided URL is a valid location to send users. * Validation is based on the configured allow list in kibana.yml. diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 4a1ad4238d31..de706ebd1296 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -950,6 +950,7 @@ export interface IBasePath { // @public export interface IExternalUrl { + isInternalUrl(relativeOrAbsoluteUrl: string): boolean; validateUrl(relativeOrAbsoluteUrl: string): URL | null; } diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index 70a21438754b..e416dced4f8a 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -19,6 +19,7 @@ exports[`DashboardEmptyScreen renders correctly with edit mode 1`] = ` }, "delete": [MockFunction], "externalUrl": Object { + "isInternalUrl": [MockFunction], "validateUrl": [MockFunction], }, "fetch": [MockFunction], @@ -343,6 +344,7 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` }, "delete": [MockFunction], "externalUrl": Object { + "isInternalUrl": [MockFunction], "validateUrl": [MockFunction], }, "fetch": [MockFunction], @@ -706,6 +708,7 @@ exports[`DashboardEmptyScreen renders correctly with view mode 1`] = ` }, "delete": [MockFunction], "externalUrl": Object { + "isInternalUrl": [MockFunction], "validateUrl": [MockFunction], }, "fetch": [MockFunction], diff --git a/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap b/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap index f38bdb9ac53f..1f8ab453a100 100644 --- a/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap +++ b/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap @@ -396,6 +396,7 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when th }, "delete": [MockFunction], "externalUrl": Object { + "isInternalUrl": [MockFunction], "validateUrl": [MockFunction], }, "fetch": [MockFunction], @@ -463,6 +464,7 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when th }, "delete": [MockFunction], "externalUrl": Object { + "isInternalUrl": [MockFunction], "validateUrl": [MockFunction], }, "fetch": [MockFunction], @@ -530,6 +532,7 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when th }, "delete": [MockFunction], "externalUrl": Object { + "isInternalUrl": [MockFunction], "validateUrl": [MockFunction], }, "fetch": [MockFunction], diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap index bfdcda0135ef..956b0b0d89ed 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap @@ -182,6 +182,7 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = ` }, "delete": [MockFunction], "externalUrl": Object { + "isInternalUrl": [MockFunction], "validateUrl": [MockFunction], }, "fetch": [MockFunction], diff --git a/src/plugins/share/jest.integration.config.js b/src/plugins/share/jest.integration.config.js new file mode 100644 index 000000000000..9cae3c1afc89 --- /dev/null +++ b/src/plugins/share/jest.integration.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_integration', + rootDir: '../../..', + roots: ['/src/plugins/share'], +}; diff --git a/src/plugins/share/public/services/short_url_redirect_app.ts b/src/plugins/share/public/services/short_url_redirect_app.ts index b647e38fc148..1df02e32bf0a 100644 --- a/src/plugins/share/public/services/short_url_redirect_app.ts +++ b/src/plugins/share/public/services/short_url_redirect_app.ts @@ -45,6 +45,9 @@ export const createShortUrlRedirectApp = ( } const url = core.http.basePath.prepend(redirectUrl); + if (!core.http.externalUrl.isInternalUrl(url)) { + throw new Error(`Can not redirect to external URL: ${url}`); + } location.href = url; diff --git a/src/plugins/share/server/url_service/http/register_url_service_routes.ts b/src/plugins/share/server/url_service/http/register_url_service_routes.ts index 35b513bebbc8..4061668d5170 100644 --- a/src/plugins/share/server/url_service/http/register_url_service_routes.ts +++ b/src/plugins/share/server/url_service/http/register_url_service_routes.ts @@ -20,7 +20,9 @@ export const registerUrlServiceRoutes = ( router: IRouter, url: ServerUrlService ) => { - registerCreateRoute(router, url); + const { http } = core; + + registerCreateRoute(router, url, http); registerGetRoute(router, url); registerDeleteRoute(router, url); registerResolveRoute(router, url); diff --git a/src/plugins/share/server/url_service/http/short_urls/integration_tests/create.test.ts b/src/plugins/share/server/url_service/http/short_urls/integration_tests/create.test.ts new file mode 100644 index 000000000000..d776aafa653a --- /dev/null +++ b/src/plugins/share/server/url_service/http/short_urls/integration_tests/create.test.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { setupServer } from 'src/core/server/test_utils'; +import { httpServiceMock } from 'src/core/server/mocks'; +import { MockUrlService } from '../../../../../common/mocks'; +import { registerCreateRoute } from '../register_create_route'; + +const url = new MockUrlService(); +const http = httpServiceMock.createSetupContract(); + +type SetupServerReturn = UnwrapPromise>; + +describe('POST /api/short_url', () => { + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + + beforeAll(async () => { + const setup = await setupServer(); + server = setup.server; + httpSetup = setup.httpSetup; + + url.locators.get = jest.fn().mockImplementation((locatorId) => { + if (locatorId === 'LEGACY_SHORT_URL_LOCATOR') { + return { id: locatorId }; + } + return undefined; + }); + + http.basePath.get = jest.fn().mockReturnValue(''); + registerCreateRoute(httpSetup.createRouter(''), url, http); + + await server.start(); + }); + + afterAll(async () => { + await server.stop(); + }); + + it('returns 200 and the short URL data for a valid internal URL', async () => { + url.shortUrls.get = jest.fn().mockReturnValue({ + create: jest.fn().mockResolvedValue({ + data: { id: 'abc123', url: '/s/abc123' }, + }), + }); + + const payload = { + locatorId: 'LEGACY_SHORT_URL_LOCATOR', + params: { + url: '/internal/app/path', + }, + }; + + await supertest(httpSetup.server.listener) + .post('/api/short_url') + .send(payload) + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ id: 'abc123', url: '/s/abc123' }); + }); + }); + + it('returns 400 if creating a short URL for an external URL', async () => { + const payload = { + locatorId: 'LEGACY_SHORT_URL_LOCATOR', + params: { + url: 'http://example.com/app/management/data/short_urls', + }, + }; + + await supertest(httpSetup.server.listener) + .post('/api/short_url') + .send(payload) + .expect(400) + .expect((res) => { + expect(res.text).toContain('Can not create a short URL for an external URL.'); + }); + }); + + it('returns 409 if the locator is not found', async () => { + const payload = { + locatorId: 'NON_EXISTENT_LOCATOR', + params: { + url: '/internal/path', + }, + }; + + // Override the locator mock for this test + (url.locators.get as jest.Mock).mockReturnValueOnce(undefined); + + await supertest(httpSetup.server.listener) + .post('/api/short_url') + .send(payload) + .expect(409) + .expect((res) => { + expect(res.text).toContain('Locator not found.'); + }); + }); +}); diff --git a/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts b/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts index 3bf50787ad77..b46c120af066 100644 --- a/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts +++ b/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts @@ -7,10 +7,50 @@ */ import { schema } from '@kbn/config-schema'; -import { IRouter } from 'kibana/server'; +import { HttpServiceSetup, IRouter } from 'kibana/server'; import { ServerUrlService } from '../../types'; -export const registerCreateRoute = (router: IRouter, url: ServerUrlService) => { +/** + * Determine if url is outside of this Kibana install. + * Copied from x-pack/plugins/security/common/is_internal_url.ts + */ +function isInternalURL(url: string, basePath = '') { + // We use the WHATWG parser TWICE with completely different dummy base URLs to ensure that the parsed URL always + // inherits the origin of the base URL. This means that the specified URL isn't an absolute URL, or a scheme-relative + // URL (//), or a scheme-relative URL with an empty host (///). Browsers may process such URLs unexpectedly due to + // backward compatibility reasons (e.g., a browser may treat `///abc.com` as just `abc.com`). For more details, refer + // to https://url.spec.whatwg.org/#concept-basic-url-parser and https://url.spec.whatwg.org/#url-representation. + let normalizedURL: URL; + try { + for (const baseURL of ['http://example.org:5601', 'https://example.com']) { + normalizedURL = new URL(url, baseURL); + if (normalizedURL.origin !== baseURL) { + return false; + } + } + } catch { + return false; + } + + // Now we need to normalize URL to make sure any relative path segments (`..`) cannot escape expected base path. + if (basePath) { + return ( + // Normalized pathname can add a leading slash, but we should also make sure it's included in + // the original URL too. We can safely use non-null assertion operator here since we know `normalizedURL` is + // always defined, otherwise we would have returned `false` already. + url.startsWith('/') && + (normalizedURL!.pathname === basePath || normalizedURL!.pathname.startsWith(`${basePath}/`)) + ); + } + + return true; +} + +export const registerCreateRoute = ( + router: IRouter, + url: ServerUrlService, + http: HttpServiceSetup +) => { router.post( { path: '/api/short_url', @@ -42,8 +82,7 @@ export const registerCreateRoute = (router: IRouter, url: ServerUrlService) => { }, }, router.handleLegacyErrors(async (ctx, req, res) => { - const savedObjects = ctx.core.savedObjects.client; - const shortUrls = url.shortUrls.get({ savedObjects }); + const { core } = ctx; const { locatorId, params, slug } = req.body; const locator = url.locators.get(locatorId); @@ -57,6 +96,16 @@ export const registerCreateRoute = (router: IRouter, url: ServerUrlService) => { }); } + const urlFromParams = (params as { url: string | undefined }).url; + if (urlFromParams && !isInternalURL(urlFromParams, http.basePath.get(req))) { + return res.customError({ + statusCode: 400, + body: 'Can not create a short URL for an external URL.', + }); + } + const savedObjects = core.savedObjects.client; + const shortUrls = url.shortUrls.get({ savedObjects }); + const shortUrl = await shortUrls.create({ locator, params, diff --git a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap index f6c604b4774d..e484c771cb7c 100644 --- a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap +++ b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap @@ -279,6 +279,7 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO }, "delete": [MockFunction], "externalUrl": Object { + "isInternalUrl": [MockFunction], "validateUrl": [MockFunction], }, "fetch": [MockFunction], diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts index bff507836d17..e2a6d0f147b0 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts @@ -66,6 +66,10 @@ class TextExternalUrl implements IExternalUrl { public validateUrl(url: string): URL | null { return this.isCorrect ? new URL(url) : null; } + + public isInternalUrl(_url: string): boolean { + return false; + } } const createDrilldown = (isExternalUrlValid: boolean = true) => { diff --git a/x-pack/plugins/reporting/common/constants.ts b/x-pack/plugins/reporting/common/constants.ts index 2bf129097be2..32a07bdeda2d 100644 --- a/x-pack/plugins/reporting/common/constants.ts +++ b/x-pack/plugins/reporting/common/constants.ts @@ -115,6 +115,13 @@ export const ILM_POLICY_NAME = 'kibana-reporting'; // Management UI route export const REPORTING_MANAGEMENT_HOME = '/app/management/insightsAndAlerting/reporting'; +// Allowed locator types for reporting: the "reportable" analytical apps we expect to redirect to during screenshotting +export const REPORTING_REDIRECT_ALLOWED_LOCATOR_TYPES = [ + 'DASHBOARD_APP_LOCATOR', + 'LENS_APP_LOCATOR', + 'VISUALIZE_APP_LOCATOR', +]; + export const REPORTING_REDIRECT_LOCATOR_STORE_KEY = '__REPORTING_REDIRECT_LOCATOR_STORE_KEY__'; /** diff --git a/x-pack/plugins/reporting/public/redirect/redirect_app.test.tsx b/x-pack/plugins/reporting/public/redirect/redirect_app.test.tsx new file mode 100644 index 000000000000..dbc47c449f02 --- /dev/null +++ b/x-pack/plugins/reporting/public/redirect/redirect_app.test.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, waitFor, screen } from '@testing-library/react'; +import { sharePluginMock } from 'src/plugins/share/public/mocks'; +import { scopedHistoryMock } from 'src/core/public/mocks'; +import { RedirectApp } from './redirect_app'; +import { REPORTING_REDIRECT_LOCATOR_STORE_KEY } from '../../common/constants'; + +const mockApiClient = { + getInfo: jest.fn(), +}; +const mockShare = sharePluginMock.createSetupContract(); +const historyMock = scopedHistoryMock.create(); +function setLocationSearch(search: string) { + window.history.pushState({}, '', search); +} + +describe('RedirectApp', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Clear the window property before each test + delete (window as any)[REPORTING_REDIRECT_LOCATOR_STORE_KEY]; + }); + + afterEach(() => { + // Reset the URL to the root after each test + window.history.pushState({}, '', '/'); + // Clean up window property + delete (window as any)[REPORTING_REDIRECT_LOCATOR_STORE_KEY]; + }); + + it('navigates using share.navigate when locator params are present in window object', async () => { + setLocationSearch('?jobId=happy'); + const locatorParams = { id: 'LENS_APP_LOCATOR', params: { foo: 'bar' } }; + // Set the locator params in window object + (window as any)[REPORTING_REDIRECT_LOCATOR_STORE_KEY] = locatorParams; + + render( + + ); + + await waitFor(() => { + expect(mockShare.navigate).toHaveBeenCalledWith(locatorParams); + }); + }); + + it('displays error when locator params are not available', async () => { + setLocationSearch('?jobId=fail'); + // Do not set the locator params + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Redirect error')).toBeInTheDocument(); + expect(screen.getByText('Could not find locator params for report')).toBeInTheDocument(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Redirect page error:'), + 'Could not find locator params for report' + ); + }); + + consoleErrorSpy.mockRestore(); + }); + + describe('non-app locator', () => { + it('throws error when using non-allowed locator type', async () => { + setLocationSearch(''); + // Set a non-allowed locator type + (window as any)[REPORTING_REDIRECT_LOCATOR_STORE_KEY] = { + id: 'LEGACY_SHORT_URL_LOCATOR', + params: {}, + }; + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + render( + + ); + + await waitFor(() => + expect( + screen.getByText( + 'Report job execution can only redirect using a locator for an expected analytical app' + ) + ).toBeInTheDocument() + ); + + expect(mockShare.navigate).not.toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Report job execution cannot redirect using LEGACY_SHORT_URL_LOCATOR` + ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Redirect page error:'), + 'Report job execution can only redirect using a locator for an expected analytical app' + ); + consoleErrorSpy.mockRestore(); + }); + }); +}); diff --git a/x-pack/plugins/reporting/public/redirect/redirect_app.tsx b/x-pack/plugins/reporting/public/redirect/redirect_app.tsx index cf027e2a4619..03db54adcfee 100644 --- a/x-pack/plugins/reporting/public/redirect/redirect_app.tsx +++ b/x-pack/plugins/reporting/public/redirect/redirect_app.tsx @@ -12,7 +12,10 @@ import { EuiTitle, EuiCallOut, EuiCodeBlock } from '@elastic/eui'; import type { ScopedHistory } from 'src/core/public'; -import { REPORTING_REDIRECT_LOCATOR_STORE_KEY } from '../../common/constants'; +import { + REPORTING_REDIRECT_LOCATOR_STORE_KEY, + REPORTING_REDIRECT_ALLOWED_LOCATOR_TYPES, +} from '../../common/constants'; import { LocatorParams } from '../../common/types'; import { ReportingAPIClient } from '../lib/reporting_api_client'; @@ -52,12 +55,19 @@ export const RedirectApp: FunctionComponent = ({ share }) => { throw new Error('Could not find locator params for report'); } + if (!REPORTING_REDIRECT_ALLOWED_LOCATOR_TYPES.includes(locatorParams.id)) { + // eslint-disable-next-line no-console + console.error(`Report job execution cannot redirect using ${locatorParams.id}`); + throw new Error( + 'Report job execution can only redirect using a locator for an expected analytical app' + ); + } + share.navigate(locatorParams); } catch (e) { setError(e); // eslint-disable-next-line no-console console.error(i18nTexts.consoleMessagePrefix, e.message); - throw e; } }, [share]);