[7.17] [SharedUX/Locators] Add limits around legacy locator (#222273) (#222720)

# Backport

This will backport the following commits from `main` to `7.17`:
- [[SharedUX/Locators] Add limits around legacy locator
(#222273)](https://github.com/elastic/kibana/pull/222273)

<!--- Backport version: 10.0.0 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Tim
Sullivan","email":"tsullivan@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-06-04T19:55:47Z","message":"[SharedUX/Locators]
Add limits around legacy locator (#222273)\n\n## Summary\n\nWe've
decided that we want to enforce sanity checks around the
\"legacy\"\nlocator and phase out its usage. This PR ensures the legacy
locator can\nnot be used from the Reporting plugin.\n\n###
Checklist\n\n- [x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common
scenarios\n\n---------\n\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"b96e7c37b83dc3f3ec08860d10413127baf13196","branchLabelMapping":{"^v9.1.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","backport:version","v9.1.0","v8.19.0","v7.17.29","v9.0.3","v8.18.3","v8.17.8"],"title":"[SharedUX/Locators]
Add limits around legacy
locator","number":222273,"url":"https://github.com/elastic/kibana/pull/222273","mergeCommit":{"message":"[SharedUX/Locators]
Add limits around legacy locator (#222273)\n\n## Summary\n\nWe've
decided that we want to enforce sanity checks around the
\"legacy\"\nlocator and phase out its usage. This PR ensures the legacy
locator can\nnot be used from the Reporting plugin.\n\n###
Checklist\n\n- [x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common
scenarios\n\n---------\n\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"b96e7c37b83dc3f3ec08860d10413127baf13196"}},"sourceBranch":"main","suggestedTargetBranches":["8.19","7.17","9.0","8.18","8.17"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/222273","number":222273,"mergeCommit":{"message":"[SharedUX/Locators]
Add limits around legacy locator (#222273)\n\n## Summary\n\nWe've
decided that we want to enforce sanity checks around the
\"legacy\"\nlocator and phase out its usage. This PR ensures the legacy
locator can\nnot be used from the Reporting plugin.\n\n###
Checklist\n\n- [x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common
scenarios\n\n---------\n\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"b96e7c37b83dc3f3ec08860d10413127baf13196"}},{"branch":"8.19","label":"v8.19.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"7.17","label":"v7.17.29","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"9.0","label":"v9.0.3","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"8.18","label":"v8.18.3","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"8.17","label":"v8.17.8","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

---------

Co-authored-by: “jeramysoucy” <jeramy.soucy@elastic.co>
This commit is contained in:
Tim Sullivan 2025-06-06 17:37:45 -07:00 committed by GitHub
parent cf7ad65fd4
commit 664f3dc887
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 458 additions and 12 deletions

View file

@ -0,0 +1,24 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [IExternalUrl](./kibana-plugin-core-public.iexternalurl.md) &gt; [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

View file

@ -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) | <p>Determines if the provided URL is a valid location to send users. Validation is based on the configured allow list in kibana.yml.</p><p>If the URL is valid, then a URL will be returned. Otherwise, this will return null.</p> |

View file

@ -0,0 +1,28 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md) &gt; [\[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<Method>;
options: RecursiveReadonly<KibanaRequestRouteOptions<Method>>;
}>;
};
```
**Returns:**
{ id: string; uuid: string; url: string; isSystemRequest: boolean; auth: { isAuthenticated: boolean; }; route: Readonly&lt;{ path: string; method: RecursiveReadonly&lt;Method&gt;; options: RecursiveReadonly&lt;KibanaRequestRouteOptions&lt;Method&gt;&gt;; }&gt;; }

View file

@ -0,0 +1,28 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md) &gt; [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<Method>;
options: RecursiveReadonly<KibanaRequestRouteOptions<Method>>;
}>;
};
```
**Returns:**
{ id: string; uuid: string; url: string; isSystemRequest: boolean; auth: { isAuthenticated: boolean; }; route: Readonly&lt;{ path: string; method: RecursiveReadonly&lt;Method&gt;; options: RecursiveReadonly&lt;KibanaRequestRouteOptions&lt;Method&gt;&gt;; }&gt;; }

View file

@ -0,0 +1,15 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md) &gt; [toString](./kibana-plugin-core-server.kibanarequest.tostring.md)
## KibanaRequest.toString() method
**Signature:**
```typescript
toString(): string;
```
**Returns:**
string

View file

@ -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';

View file

@ -50,20 +50,33 @@ function normalizeProtocol(protocol: string) {
return protocol.endsWith(':') ? protocol.slice(0, -1).toLowerCase() : protocol.toLowerCase();
}
const createIsInternalUrlValidation = (
location: Pick<Location, 'href'>,
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<Location, 'href'>,
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<IExternalUrl> {
const { policy } = injectedMetadata.getExternalUrlConfig();
return {
isInternalUrl: createIsInternalUrlValidation(location, serverBasePath),
validateUrl: createExternalUrlValidation(policy, location, serverBasePath),
};
}

View file

@ -37,6 +37,7 @@ const createServiceMock = ({
},
externalUrl: {
validateUrl: jest.fn(),
isInternalUrl: jest.fn(),
},
addLoadingCountSource: jest.fn(),
getLoadingCount$: jest.fn().mockReturnValue(new BehaviorSubject(0)),

View file

@ -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.

View file

@ -950,6 +950,7 @@ export interface IBasePath {
// @public
export interface IExternalUrl {
isInternalUrl(relativeOrAbsoluteUrl: string): boolean;
validateUrl(relativeOrAbsoluteUrl: string): URL | null;
}

View file

@ -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],

View file

@ -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],

View file

@ -182,6 +182,7 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = `
},
"delete": [MockFunction],
"externalUrl": Object {
"isInternalUrl": [MockFunction],
"validateUrl": [MockFunction],
},
"fetch": [MockFunction],

View file

@ -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: ['<rootDir>/src/plugins/share'],
};

View file

@ -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;

View file

@ -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);

View file

@ -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<ReturnType<typeof setupServer>>;
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.');
});
});
});

View file

@ -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,

View file

@ -279,6 +279,7 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO
},
"delete": [MockFunction],
"externalUrl": Object {
"isInternalUrl": [MockFunction],
"validateUrl": [MockFunction],
},
"fetch": [MockFunction],

View file

@ -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) => {

View file

@ -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__';
/**

View file

@ -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(
<RedirectApp apiClient={mockApiClient as any} share={mockShare} history={historyMock} />
);
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(
<RedirectApp apiClient={mockApiClient as any} share={mockShare} history={historyMock} />
);
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(
<RedirectApp apiClient={mockApiClient as any} share={mockShare} history={historyMock} />
);
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();
});
});
});

View file

@ -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<Props> = ({ 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]);