From c0bf0540dc6d8fee1ef6c541fca7a0f066b44f82 Mon Sep 17 00:00:00 2001 From: Vadim Kibana <82822460+vadimkibana@users.noreply.github.com> Date: Tue, 28 Sep 2021 10:34:25 +0200 Subject: [PATCH] Short URLs (#107859) --- package.json | 1 + .../common/url_service/__tests__/setup.ts | 16 ++ src/plugins/share/common/url_service/index.ts | 1 + .../locators/legacy_short_url_locator.ts | 47 +++++ .../common/url_service/locators/locator.ts | 2 + .../locators/short_url_assert_valid.test.ts | 49 +++++ .../locators/short_url_assert_valid.ts | 13 ++ .../common/url_service/locators/types.ts | 2 + src/plugins/share/common/url_service/mocks.ts | 16 ++ .../common/url_service/short_urls/index.ts | 9 + .../common/url_service/short_urls/types.ts | 141 +++++++++++++ .../share/common/url_service/url_service.ts | 12 +- .../share/public/lib/url_shortener.test.ts | 23 +- src/plugins/share/public/lib/url_shortener.ts | 16 +- src/plugins/share/public/mocks.ts | 17 ++ src/plugins/share/public/plugin.ts | 23 +- .../services/short_url_redirect_app.test.ts | 35 ---- .../public/services/short_url_redirect_app.ts | 35 +++- src/plugins/share/server/plugin.ts | 25 ++- .../share/server/routes/create_routes.ts | 23 -- src/plugins/share/server/routes/get.ts | 45 ---- src/plugins/share/server/routes/goto.ts | 59 ------ .../routes/lib/short_url_assert_valid.test.ts | 55 ----- .../routes/lib/short_url_assert_valid.ts | 34 --- .../routes/lib/short_url_lookup.test.ts | 110 ---------- .../server/routes/lib/short_url_lookup.ts | 77 ------- .../share/server/routes/shorten_url.ts | 38 ---- src/plugins/share/server/saved_objects/url.ts | 16 ++ .../http/register_url_service_routes.ts | 29 +++ .../http/short_urls/register_create_route.ts | 66 ++++++ .../http/short_urls/register_delete_route.ts | 41 ++++ .../http/short_urls/register_get_route.ts | 40 ++++ .../http/short_urls/register_goto_route.ts | 33 +++ .../http/short_urls/register_resolve_route.ts | 40 ++++ .../short_urls/register_shorten_url_route.ts | 23 ++ src/plugins/share/server/url_service/index.ts | 10 + .../server/url_service/short_urls/index.ts | 11 + .../short_urls/short_url_client.test.ts | 180 ++++++++++++++++ .../short_urls/short_url_client.ts | 107 ++++++++++ .../short_urls/short_url_client_factory.ts | 49 +++++ .../storage/memory_short_url_storage.test.ts | 177 ++++++++++++++++ .../storage/memory_short_url_storage.ts | 58 ++++++ .../storage/saved_object_short_url_storage.ts | 164 +++++++++++++++ .../server/url_service/short_urls/types.ts | 44 ++++ .../url_service/short_urls/util.test.ts | 69 ++++++ .../server/url_service/short_urls/util.ts | 25 +++ src/plugins/share/server/url_service/types.ts | 12 ++ test/api_integration/apis/index.ts | 2 +- .../apis/short_url/create_short_url/index.ts | 16 ++ .../apis/short_url/create_short_url/main.ts | 139 ++++++++++++ .../short_url/create_short_url/validation.ts | 68 ++++++ .../apis/short_url/delete_short_url/index.ts | 16 ++ .../apis/short_url/delete_short_url/main.ts | 56 +++++ .../short_url/delete_short_url/validation.ts | 40 ++++ .../apis/short_url/get_short_url/index.ts | 16 ++ .../apis/short_url/get_short_url/main.ts | 51 +++++ .../short_url/get_short_url/validation.ts | 40 ++++ test/api_integration/apis/short_url/index.ts | 18 ++ .../apis/short_url/resolve_short_url/index.ts | 16 ++ .../apis/short_url/resolve_short_url/main.ts | 55 +++++ .../short_url/resolve_short_url/validation.ts | 40 ++++ test/api_integration/apis/shorten/index.js | 52 ----- .../functional/apps/discover/_shared_links.ts | 4 +- .../apm_plugin/mock_apm_plugin_context.tsx | 1 + x-pack/test/api_integration/apis/index.ts | 1 - .../apis/short_urls/feature_controls.ts | 197 ------------------ .../api_integration/apis/short_urls/index.ts | 14 -- yarn.lock | 5 + 68 files changed, 2177 insertions(+), 788 deletions(-) create mode 100644 src/plugins/share/common/url_service/locators/legacy_short_url_locator.ts create mode 100644 src/plugins/share/common/url_service/locators/short_url_assert_valid.test.ts create mode 100644 src/plugins/share/common/url_service/locators/short_url_assert_valid.ts create mode 100644 src/plugins/share/common/url_service/short_urls/index.ts create mode 100644 src/plugins/share/common/url_service/short_urls/types.ts delete mode 100644 src/plugins/share/public/services/short_url_redirect_app.test.ts delete mode 100644 src/plugins/share/server/routes/create_routes.ts delete mode 100644 src/plugins/share/server/routes/get.ts delete mode 100644 src/plugins/share/server/routes/goto.ts delete mode 100644 src/plugins/share/server/routes/lib/short_url_assert_valid.test.ts delete mode 100644 src/plugins/share/server/routes/lib/short_url_assert_valid.ts delete mode 100644 src/plugins/share/server/routes/lib/short_url_lookup.test.ts delete mode 100644 src/plugins/share/server/routes/lib/short_url_lookup.ts delete mode 100644 src/plugins/share/server/routes/shorten_url.ts create mode 100644 src/plugins/share/server/url_service/http/register_url_service_routes.ts create mode 100644 src/plugins/share/server/url_service/http/short_urls/register_create_route.ts create mode 100644 src/plugins/share/server/url_service/http/short_urls/register_delete_route.ts create mode 100644 src/plugins/share/server/url_service/http/short_urls/register_get_route.ts create mode 100644 src/plugins/share/server/url_service/http/short_urls/register_goto_route.ts create mode 100644 src/plugins/share/server/url_service/http/short_urls/register_resolve_route.ts create mode 100644 src/plugins/share/server/url_service/http/short_urls/register_shorten_url_route.ts create mode 100644 src/plugins/share/server/url_service/index.ts create mode 100644 src/plugins/share/server/url_service/short_urls/index.ts create mode 100644 src/plugins/share/server/url_service/short_urls/short_url_client.test.ts create mode 100644 src/plugins/share/server/url_service/short_urls/short_url_client.ts create mode 100644 src/plugins/share/server/url_service/short_urls/short_url_client_factory.ts create mode 100644 src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.test.ts create mode 100644 src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.ts create mode 100644 src/plugins/share/server/url_service/short_urls/storage/saved_object_short_url_storage.ts create mode 100644 src/plugins/share/server/url_service/short_urls/types.ts create mode 100644 src/plugins/share/server/url_service/short_urls/util.test.ts create mode 100644 src/plugins/share/server/url_service/short_urls/util.ts create mode 100644 src/plugins/share/server/url_service/types.ts create mode 100644 test/api_integration/apis/short_url/create_short_url/index.ts create mode 100644 test/api_integration/apis/short_url/create_short_url/main.ts create mode 100644 test/api_integration/apis/short_url/create_short_url/validation.ts create mode 100644 test/api_integration/apis/short_url/delete_short_url/index.ts create mode 100644 test/api_integration/apis/short_url/delete_short_url/main.ts create mode 100644 test/api_integration/apis/short_url/delete_short_url/validation.ts create mode 100644 test/api_integration/apis/short_url/get_short_url/index.ts create mode 100644 test/api_integration/apis/short_url/get_short_url/main.ts create mode 100644 test/api_integration/apis/short_url/get_short_url/validation.ts create mode 100644 test/api_integration/apis/short_url/index.ts create mode 100644 test/api_integration/apis/short_url/resolve_short_url/index.ts create mode 100644 test/api_integration/apis/short_url/resolve_short_url/main.ts create mode 100644 test/api_integration/apis/short_url/resolve_short_url/validation.ts delete mode 100644 test/api_integration/apis/shorten/index.js delete mode 100644 x-pack/test/api_integration/apis/short_urls/feature_controls.ts delete mode 100644 x-pack/test/api_integration/apis/short_urls/index.ts diff --git a/package.json b/package.json index 67324dc382342..25339379cec98 100644 --- a/package.json +++ b/package.json @@ -318,6 +318,7 @@ "puid": "1.0.7", "puppeteer": "^8.0.0", "query-string": "^6.13.2", + "random-word-slugs": "^0.0.5", "raw-loader": "^3.1.0", "rbush": "^3.0.1", "re-resizable": "^6.1.1", diff --git a/src/plugins/share/common/url_service/__tests__/setup.ts b/src/plugins/share/common/url_service/__tests__/setup.ts index 1662b1f4a2d49..239b2554e663a 100644 --- a/src/plugins/share/common/url_service/__tests__/setup.ts +++ b/src/plugins/share/common/url_service/__tests__/setup.ts @@ -37,6 +37,22 @@ export const urlServiceTestSetup = (partialDeps: Partial getUrl: async () => { throw new Error('not implemented'); }, + shortUrls: { + get: () => ({ + create: async () => { + throw new Error('Not implemented.'); + }, + get: async () => { + throw new Error('Not implemented.'); + }, + delete: async () => { + throw new Error('Not implemented.'); + }, + resolve: async () => { + throw new Error('Not implemented.'); + }, + }), + }, ...partialDeps, }; const service = new UrlService(deps); diff --git a/src/plugins/share/common/url_service/index.ts b/src/plugins/share/common/url_service/index.ts index 84f74356bcf18..6e9fd30979d27 100644 --- a/src/plugins/share/common/url_service/index.ts +++ b/src/plugins/share/common/url_service/index.ts @@ -8,3 +8,4 @@ export * from './url_service'; export * from './locators'; +export * from './short_urls'; diff --git a/src/plugins/share/common/url_service/locators/legacy_short_url_locator.ts b/src/plugins/share/common/url_service/locators/legacy_short_url_locator.ts new file mode 100644 index 0000000000000..0a2064478f7ba --- /dev/null +++ b/src/plugins/share/common/url_service/locators/legacy_short_url_locator.ts @@ -0,0 +1,47 @@ +/* + * 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 type { SerializableRecord } from '@kbn/utility-types'; +import type { KibanaLocation, LocatorDefinition } from '../../url_service'; +import { shortUrlAssertValid } from './short_url_assert_valid'; + +export const LEGACY_SHORT_URL_LOCATOR_ID = 'LEGACY_SHORT_URL_LOCATOR'; + +export interface LegacyShortUrlLocatorParams extends SerializableRecord { + url: string; +} + +export class LegacyShortUrlLocatorDefinition + implements LocatorDefinition +{ + public readonly id = LEGACY_SHORT_URL_LOCATOR_ID; + + public async getLocation(params: LegacyShortUrlLocatorParams): Promise { + const { url } = params; + + shortUrlAssertValid(url); + + const match = url.match(/^.*\/app\/([^\/#]+)(.+)$/); + + if (!match) { + throw new Error('Unexpected URL path.'); + } + + const [, app, path] = match; + + if (!app || !path) { + throw new Error('Could not parse URL path.'); + } + + return { + app, + path, + state: {}, + }; + } +} diff --git a/src/plugins/share/common/url_service/locators/locator.ts b/src/plugins/share/common/url_service/locators/locator.ts index 12061c82bb551..fc970e2c7a490 100644 --- a/src/plugins/share/common/url_service/locators/locator.ts +++ b/src/plugins/share/common/url_service/locators/locator.ts @@ -43,12 +43,14 @@ export interface LocatorDependencies { } export class Locator

implements LocatorPublic

{ + public readonly id: string; public readonly migrations: PersistableState

['migrations']; constructor( public readonly definition: LocatorDefinition

, protected readonly deps: LocatorDependencies ) { + this.id = definition.id; this.migrations = definition.migrations || {}; } diff --git a/src/plugins/share/common/url_service/locators/short_url_assert_valid.test.ts b/src/plugins/share/common/url_service/locators/short_url_assert_valid.test.ts new file mode 100644 index 0000000000000..d9de20d447a51 --- /dev/null +++ b/src/plugins/share/common/url_service/locators/short_url_assert_valid.test.ts @@ -0,0 +1,49 @@ +/* + * 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 { shortUrlAssertValid } from './short_url_assert_valid'; + +describe('shortUrlAssertValid()', () => { + const invalid = [ + ['protocol', 'http://localhost:5601/app/kibana'], + ['protocol', 'https://localhost:5601/app/kibana'], + ['protocol', 'mailto:foo@bar.net'], + ['protocol', 'javascript:alert("hi")'], // eslint-disable-line no-script-url + ['hostname', 'localhost/app/kibana'], // according to spec, this is not a valid URL -- you cannot specify a hostname without a protocol + ['hostname and port', 'local.host:5601/app/kibana'], // parser detects 'local.host' as the protocol + ['hostname and auth', 'user:pass@localhost.net/app/kibana'], // parser detects 'user' as the protocol + ['path traversal', '/app/../../not-kibana'], // fails because there are >2 path parts + ['path traversal', '/../not-kibana'], // fails because first path part is not 'app' + ['base path', '/base/app/kibana'], // fails because there are >2 path parts + ['path with an extra leading slash', '//foo/app/kibana'], // parser detects 'foo' as the hostname + ['path with an extra leading slash', '///app/kibana'], // parser detects '' as the hostname + ['path without app', '/foo/kibana'], // fails because first path part is not 'app' + ['path without appId', '/app/'], // fails because there is only one path part (leading and trailing slashes are trimmed) + ]; + + invalid.forEach(([desc, url, error]) => { + it(`fails when url has ${desc as string}`, () => { + expect(() => shortUrlAssertValid(url as string)).toThrow(); + }); + }); + + const valid = [ + '/app/kibana', + '/app/kibana/', // leading and trailing slashes are trimmed + '/app/monitoring#angular/route', + '/app/text#document-id', + '/app/some?with=query', + '/app/some?with=query#and-a-hash', + ]; + + valid.forEach((url) => { + it(`allows ${url}`, () => { + shortUrlAssertValid(url); + }); + }); +}); diff --git a/src/plugins/share/common/url_service/locators/short_url_assert_valid.ts b/src/plugins/share/common/url_service/locators/short_url_assert_valid.ts new file mode 100644 index 0000000000000..c0d39a71b4e4a --- /dev/null +++ b/src/plugins/share/common/url_service/locators/short_url_assert_valid.ts @@ -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. + */ + +const REGEX = /^\/app\/[^/]+.+$/; + +export function shortUrlAssertValid(url: string) { + if (!REGEX.test(url) || url.includes('/../')) throw new Error(`Invalid short URL: ${url}`); +} diff --git a/src/plugins/share/common/url_service/locators/types.ts b/src/plugins/share/common/url_service/locators/types.ts index 107188b405047..ab0efa9b2375a 100644 --- a/src/plugins/share/common/url_service/locators/types.ts +++ b/src/plugins/share/common/url_service/locators/types.ts @@ -53,6 +53,8 @@ export interface LocatorDefinition

* Public interface of a registered locator. */ export interface LocatorPublic

extends PersistableState

{ + readonly id: string; + /** * Returns a reference to a Kibana client-side location. * diff --git a/src/plugins/share/common/url_service/mocks.ts b/src/plugins/share/common/url_service/mocks.ts index a4966e5a7b6e6..dd86e2398589e 100644 --- a/src/plugins/share/common/url_service/mocks.ts +++ b/src/plugins/share/common/url_service/mocks.ts @@ -18,6 +18,22 @@ export class MockUrlService extends UrlService { getUrl: async ({ app, path }, { absolute }) => { return `${absolute ? 'http://localhost:8888' : ''}/app/${app}${path}`; }, + shortUrls: { + get: () => ({ + create: async () => { + throw new Error('Not implemented.'); + }, + get: async () => { + throw new Error('Not implemented.'); + }, + delete: async () => { + throw new Error('Not implemented.'); + }, + resolve: async () => { + throw new Error('Not implemented.'); + }, + }), + }, }); } } diff --git a/src/plugins/share/common/url_service/short_urls/index.ts b/src/plugins/share/common/url_service/short_urls/index.ts new file mode 100644 index 0000000000000..12594660136d8 --- /dev/null +++ b/src/plugins/share/common/url_service/short_urls/index.ts @@ -0,0 +1,9 @@ +/* + * 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. + */ + +export * from './types'; diff --git a/src/plugins/share/common/url_service/short_urls/types.ts b/src/plugins/share/common/url_service/short_urls/types.ts new file mode 100644 index 0000000000000..db744a25f9f79 --- /dev/null +++ b/src/plugins/share/common/url_service/short_urls/types.ts @@ -0,0 +1,141 @@ +/* + * 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 { SerializableRecord } from '@kbn/utility-types'; +import { VersionedState } from 'src/plugins/kibana_utils/common'; +import { LocatorPublic } from '../locators'; + +/** + * A factory for Short URL Service. We need this factory as the dependency + * injection is different between the server and the client. On the server, + * the Short URL Service needs a saved object client scoped to the current + * request and the current Kibana version. On the client, the Short URL Service + * needs no dependencies. + */ +export interface IShortUrlClientFactory { + get(dependencies: D): IShortUrlClient; +} + +/** + * CRUD-like API for short URLs. + */ +export interface IShortUrlClient { + /** + * Create a new short URL. + * + * @param locator The locator for the URL. + * @param param The parameters for the URL. + * @returns The created short URL. + */ + create

(params: ShortUrlCreateParams

): Promise>; + + /** + * Delete a short URL. + * + * @param slug The ID of the short URL. + */ + delete(id: string): Promise; + + /** + * Fetch a short URL. + * + * @param id The ID of the short URL. + */ + get(id: string): Promise; + + /** + * Fetch a short URL by its slug. + * + * @param slug The slug of the short URL. + */ + resolve(slug: string): Promise; +} + +/** + * New short URL creation parameters. + */ +export interface ShortUrlCreateParams

{ + /** + * Locator which will be used to resolve the short URL. + */ + locator: LocatorPublic

; + + /** + * Locator parameters which will be used to resolve the short URL. + */ + params: P; + + /** + * Optional, short URL slug - the part that will be used to resolve the short + * URL. This part will be visible to the user, it can have user-friendly text. + */ + slug?: string; + + /** + * Whether to generate a slug automatically. If `true`, the slug will be + * a human-readable text consisting of three worlds: "--". + */ + humanReadableSlug?: boolean; +} + +/** + * A representation of a short URL. + */ +export interface ShortUrl { + /** + * Serializable state of the short URL, which is stored in Kibana. + */ + readonly data: ShortUrlData; +} + +/** + * A representation of a short URL's data. + */ +export interface ShortUrlData { + /** + * Unique ID of the short URL. + */ + readonly id: string; + + /** + * The slug of the short URL, the part after the `/` in the URL. + */ + readonly slug: string; + + /** + * Number of times the short URL has been resolved. + */ + readonly accessCount: number; + + /** + * The timestamp of the last time the short URL was resolved. + */ + readonly accessDate: number; + + /** + * The timestamp when the short URL was created. + */ + readonly createDate: number; + + /** + * The timestamp when the short URL was last modified. + */ + readonly locator: LocatorData; +} + +/** + * Represents a serializable state of a locator. Includes locator ID, version + * and its params. + */ +export interface LocatorData + extends VersionedState { + /** + * Locator ID. + */ + id: string; +} diff --git a/src/plugins/share/common/url_service/url_service.ts b/src/plugins/share/common/url_service/url_service.ts index 5daba1500cdfd..dedb81720865d 100644 --- a/src/plugins/share/common/url_service/url_service.ts +++ b/src/plugins/share/common/url_service/url_service.ts @@ -7,19 +7,25 @@ */ import { LocatorClient, LocatorClientDependencies } from './locators'; +import { IShortUrlClientFactory } from './short_urls'; -export type UrlServiceDependencies = LocatorClientDependencies; +export interface UrlServiceDependencies extends LocatorClientDependencies { + shortUrls: IShortUrlClientFactory; +} /** * Common URL Service client interface for server-side and client-side. */ -export class UrlService { +export class UrlService { /** * Client to work with locators. */ public readonly locators: LocatorClient; - constructor(protected readonly deps: UrlServiceDependencies) { + public readonly shortUrls: IShortUrlClientFactory; + + constructor(protected readonly deps: UrlServiceDependencies) { this.locators = new LocatorClient(deps); + this.shortUrls = deps.shortUrls; } } diff --git a/src/plugins/share/public/lib/url_shortener.test.ts b/src/plugins/share/public/lib/url_shortener.test.ts index 865fbc6f7e909..12c2a769d7037 100644 --- a/src/plugins/share/public/lib/url_shortener.test.ts +++ b/src/plugins/share/public/lib/url_shortener.test.ts @@ -13,7 +13,7 @@ describe('Url shortener', () => { let postStub: jest.Mock; beforeEach(() => { - postStub = jest.fn(() => Promise.resolve({ urlId: shareId })); + postStub = jest.fn(() => Promise.resolve({ id: shareId })); }); describe('Shorten without base path', () => { @@ -23,9 +23,6 @@ describe('Url shortener', () => { post: postStub, }); expect(shortUrl).toBe(`http://localhost:5601/goto/${shareId}`); - expect(postStub).toHaveBeenCalledWith(`/api/shorten_url`, { - body: '{"url":"/app/kibana#123"}', - }); }); it('should shorten urls without a port', async () => { @@ -34,9 +31,6 @@ describe('Url shortener', () => { post: postStub, }); expect(shortUrl).toBe(`http://localhost/goto/${shareId}`); - expect(postStub).toHaveBeenCalledWith(`/api/shorten_url`, { - body: '{"url":"/app/kibana#123"}', - }); }); }); @@ -49,9 +43,6 @@ describe('Url shortener', () => { post: postStub, }); expect(shortUrl).toBe(`http://localhost:5601${basePath}/goto/${shareId}`); - expect(postStub).toHaveBeenCalledWith(`/api/shorten_url`, { - body: '{"url":"/app/kibana#123"}', - }); }); it('should shorten urls without a port', async () => { @@ -60,9 +51,6 @@ describe('Url shortener', () => { post: postStub, }); expect(shortUrl).toBe(`http://localhost${basePath}/goto/${shareId}`); - expect(postStub).toHaveBeenCalledWith(`/api/shorten_url`, { - body: '{"url":"/app/kibana#123"}', - }); }); it('should shorten urls with a query string', async () => { @@ -71,9 +59,6 @@ describe('Url shortener', () => { post: postStub, }); expect(shortUrl).toBe(`http://localhost${basePath}/goto/${shareId}`); - expect(postStub).toHaveBeenCalledWith(`/api/shorten_url`, { - body: '{"url":"/app/kibana?foo#123"}', - }); }); it('should shorten urls without a hash', async () => { @@ -82,9 +67,6 @@ describe('Url shortener', () => { post: postStub, }); expect(shortUrl).toBe(`http://localhost${basePath}/goto/${shareId}`); - expect(postStub).toHaveBeenCalledWith(`/api/shorten_url`, { - body: '{"url":"/app/kibana"}', - }); }); it('should shorten urls with a query string in the hash', async () => { @@ -95,9 +77,6 @@ describe('Url shortener', () => { post: postStub, }); expect(shortUrl).toBe(`http://localhost${basePath}/goto/${shareId}`); - expect(postStub).toHaveBeenCalledWith(`/api/shorten_url`, { - body: '{"url":"/app/discover#/?_g=(refreshInterval:(pause:!f,value:0),time:(from:now-15m,mode:quick,to:now))&_a=(columns:!(_source),index:%27logstash-*%27,interval:auto,query:(query_string:(analyze_wildcard:!t,query:%27*%27)),sort:!(%27@timestamp%27,desc))"}', - }); }); }); }); diff --git a/src/plugins/share/public/lib/url_shortener.ts b/src/plugins/share/public/lib/url_shortener.ts index 1b2c7020defab..6d0b7ae91e341 100644 --- a/src/plugins/share/public/lib/url_shortener.ts +++ b/src/plugins/share/public/lib/url_shortener.ts @@ -8,26 +8,34 @@ import url from 'url'; import { HttpStart } from 'kibana/public'; -import { CREATE_PATH, getGotoPath } from '../../common/short_url_routes'; +import { getGotoPath } from '../../common/short_url_routes'; +import { LEGACY_SHORT_URL_LOCATOR_ID } from '../../common/url_service/locators/legacy_short_url_locator'; export async function shortenUrl( absoluteUrl: string, { basePath, post }: { basePath: string; post: HttpStart['post'] } ) { const parsedUrl = url.parse(absoluteUrl); + if (!parsedUrl || !parsedUrl.path) { return; } + const path = parsedUrl.path.replace(basePath, ''); const hash = parsedUrl.hash ? parsedUrl.hash : ''; const relativeUrl = path + hash; + const body = JSON.stringify({ + locatorId: LEGACY_SHORT_URL_LOCATOR_ID, + params: { url: relativeUrl }, + }); - const body = JSON.stringify({ url: relativeUrl }); + const resp = await post('/api/short_url', { + body, + }); - const resp = await post(CREATE_PATH, { body }); return url.format({ protocol: parsedUrl.protocol, host: parsedUrl.host, - pathname: `${basePath}${getGotoPath(resp.urlId)}`, + pathname: `${basePath}${getGotoPath(resp.id)}`, }); } diff --git a/src/plugins/share/public/mocks.ts b/src/plugins/share/public/mocks.ts index 624dad50879eb..4b8a3b915d13d 100644 --- a/src/plugins/share/public/mocks.ts +++ b/src/plugins/share/public/mocks.ts @@ -18,6 +18,22 @@ const url = new UrlService({ getUrl: async ({ app, path }, { absolute }) => { return `${absolute ? 'http://localhost:8888' : ''}/app/${app}${path}`; }, + shortUrls: { + get: () => ({ + create: async () => { + throw new Error('Not implemented'); + }, + get: async () => { + throw new Error('Not implemented'); + }, + delete: async () => { + throw new Error('Not implemented'); + }, + resolve: async () => { + throw new Error('Not implemented.'); + }, + }), + }, }); const createSetupContract = (): Setup => { @@ -47,6 +63,7 @@ const createStartContract = (): Start => { const createLocator = (): jest.Mocked< LocatorPublic > => ({ + id: 'MOCK_LOCATOR', getLocation: jest.fn(), getUrl: jest.fn(), getRedirectUrl: jest.fn(), diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts index 101aac8a019dd..26b5c7e753a2e 100644 --- a/src/plugins/share/public/plugin.ts +++ b/src/plugins/share/public/plugin.ts @@ -21,6 +21,7 @@ import { import { UrlService } from '../common/url_service'; import { RedirectManager } from './url_service'; import type { RedirectOptions } from '../common/url_service/locators/redirect'; +import { LegacyShortUrlLocatorDefinition } from '../common/url_service/locators/legacy_short_url_locator'; export interface ShareSetupDependencies { securityOss?: SecurityOssPluginSetup; @@ -86,8 +87,6 @@ export class SharePlugin implements Plugin { const { application, http } = core; const { basePath } = http; - application.register(createShortUrlRedirectApp(core, window.location)); - this.url = new UrlService({ baseUrl: basePath.publicBaseUrl || basePath.serverBasePath, version: this.initializerContext.env.packageInfo.version, @@ -107,8 +106,28 @@ export class SharePlugin implements Plugin { }); return url; }, + shortUrls: { + get: () => ({ + create: async () => { + throw new Error('Not implemented'); + }, + get: async () => { + throw new Error('Not implemented'); + }, + delete: async () => { + throw new Error('Not implemented'); + }, + resolve: async () => { + throw new Error('Not implemented.'); + }, + }), + }, }); + this.url.locators.create(new LegacyShortUrlLocatorDefinition()); + + application.register(createShortUrlRedirectApp(core, window.location, this.url)); + this.redirectManager = new RedirectManager({ url: this.url, }); diff --git a/src/plugins/share/public/services/short_url_redirect_app.test.ts b/src/plugins/share/public/services/short_url_redirect_app.test.ts deleted file mode 100644 index 7a57a1c2129ca..0000000000000 --- a/src/plugins/share/public/services/short_url_redirect_app.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 { createShortUrlRedirectApp } from './short_url_redirect_app'; -import { coreMock } from '../../../../core/public/mocks'; -import { hashUrl } from '../../../kibana_utils/public'; - -jest.mock('../../../kibana_utils/public', () => ({ hashUrl: jest.fn((x) => `${x}/hashed`) })); - -describe('short_url_redirect_app', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should fetch url and redirect to hashed version', async () => { - const coreSetup = coreMock.createSetup({ basePath: 'base' }); - coreSetup.http.get.mockResolvedValueOnce({ url: '/app/abc' }); - const locationMock = { pathname: '/base/goto/12345', href: '' } as Location; - - const { mount } = createShortUrlRedirectApp(coreSetup, locationMock); - await mount(); - - // check for fetching the complete URL - expect(coreSetup.http.get).toHaveBeenCalledWith('/api/short_url/12345'); - // check for hashing the URL returned from the server - expect(hashUrl).toHaveBeenCalledWith('/app/abc'); - // check for redirecting to the prepended path - expect(locationMock.href).toEqual('base/app/abc/hashed'); - }); -}); 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 935cef65f9560..b647e38fc1482 100644 --- a/src/plugins/share/public/services/short_url_redirect_app.ts +++ b/src/plugins/share/public/services/short_url_redirect_app.ts @@ -8,24 +8,43 @@ import { CoreSetup } from 'kibana/public'; import { getUrlIdFromGotoRoute, getUrlPath, GOTO_PREFIX } from '../../common/short_url_routes'; +import { + LEGACY_SHORT_URL_LOCATOR_ID, + LegacyShortUrlLocatorParams, +} from '../../common/url_service/locators/legacy_short_url_locator'; +import type { UrlService, ShortUrlData } from '../../common/url_service'; -export const createShortUrlRedirectApp = (core: CoreSetup, location: Location) => ({ +export const createShortUrlRedirectApp = ( + core: CoreSetup, + location: Location, + urlService: UrlService +) => ({ id: 'short_url_redirect', appRoute: GOTO_PREFIX, chromeless: true, title: 'Short URL Redirect', async mount() { const urlId = getUrlIdFromGotoRoute(location.pathname); + if (!urlId) throw new Error('Url id not present in path'); - if (!urlId) { - throw new Error('Url id not present in path'); + const response = await core.http.get(getUrlPath(urlId)); + const locator = urlService.locators.get(response.locator.id); + + if (!locator) throw new Error(`Locator [id = ${response.locator.id}] not found.`); + + if (response.locator.id !== LEGACY_SHORT_URL_LOCATOR_ID) { + await locator.navigate(response.locator.state, { replace: true }); + return () => {}; + } + + let redirectUrl = (response.locator.state as LegacyShortUrlLocatorParams).url; + const storeInSessionStorage = core.uiSettings.get('state:storeInSessionStorage'); + if (storeInSessionStorage) { + const { hashUrl } = await import('../../../kibana_utils/public'); + redirectUrl = hashUrl(redirectUrl); } - const response = await core.http.get<{ url: string }>(getUrlPath(urlId)); - const redirectUrl = response.url; - const { hashUrl } = await import('../../../kibana_utils/public'); - const hashedUrl = hashUrl(redirectUrl); - const url = core.http.basePath.prepend(hashedUrl); + const url = core.http.basePath.prepend(redirectUrl); location.href = url; diff --git a/src/plugins/share/server/plugin.ts b/src/plugins/share/server/plugin.ts index 18bb72ae24869..f0e4abf9eb589 100644 --- a/src/plugins/share/server/plugin.ts +++ b/src/plugins/share/server/plugin.ts @@ -9,25 +9,30 @@ import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; import { CoreSetup, Plugin, PluginInitializerContext } from 'kibana/server'; -import { createRoutes } from './routes/create_routes'; import { url } from './saved_objects'; import { CSV_SEPARATOR_SETTING, CSV_QUOTE_VALUES_SETTING } from '../common/constants'; import { UrlService } from '../common/url_service'; +import { ServerUrlService, ServerShortUrlClientFactory } from './url_service'; +import { registerUrlServiceRoutes } from './url_service/http/register_url_service_routes'; +import { LegacyShortUrlLocatorDefinition } from '../common/url_service/locators/legacy_short_url_locator'; /** @public */ export interface SharePluginSetup { - url: UrlService; + url: ServerUrlService; } /** @public */ export interface SharePluginStart { - url: UrlService; + url: ServerUrlService; } export class SharePlugin implements Plugin { - private url?: UrlService; + private url?: ServerUrlService; + private version: string; - constructor(private readonly initializerContext: PluginInitializerContext) {} + constructor(private readonly initializerContext: PluginInitializerContext) { + this.version = initializerContext.env.packageInfo.version; + } public setup(core: CoreSetup) { this.url = new UrlService({ @@ -39,9 +44,17 @@ export class SharePlugin implements Plugin { getUrl: async () => { throw new Error('Locator .getUrl() currently is not supported on the server.'); }, + shortUrls: new ServerShortUrlClientFactory({ + currentVersion: this.version, + }), }); - createRoutes(core, this.initializerContext.logger.get()); + this.url.locators.create(new LegacyShortUrlLocatorDefinition()); + + const router = core.http.createRouter(); + + registerUrlServiceRoutes(core, router, this.url); + core.savedObjects.registerType(url); core.uiSettings.register({ [CSV_SEPARATOR_SETTING]: { diff --git a/src/plugins/share/server/routes/create_routes.ts b/src/plugins/share/server/routes/create_routes.ts deleted file mode 100644 index dbe0ebdc1c64c..0000000000000 --- a/src/plugins/share/server/routes/create_routes.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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 { CoreSetup, Logger } from 'kibana/server'; - -import { shortUrlLookupProvider } from './lib/short_url_lookup'; -import { createGotoRoute } from './goto'; -import { createShortenUrlRoute } from './shorten_url'; -import { createGetterRoute } from './get'; - -export function createRoutes({ http }: CoreSetup, logger: Logger) { - const shortUrlLookup = shortUrlLookupProvider({ logger }); - const router = http.createRouter(); - - createGotoRoute({ router, shortUrlLookup, http }); - createGetterRoute({ router, shortUrlLookup, http }); - createShortenUrlRoute({ router, shortUrlLookup }); -} diff --git a/src/plugins/share/server/routes/get.ts b/src/plugins/share/server/routes/get.ts deleted file mode 100644 index 181cf5b824e4c..0000000000000 --- a/src/plugins/share/server/routes/get.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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 { CoreSetup, IRouter } from 'kibana/server'; -import { schema } from '@kbn/config-schema'; - -import { shortUrlAssertValid } from './lib/short_url_assert_valid'; -import { ShortUrlLookupService } from './lib/short_url_lookup'; -import { getUrlPath } from '../../common/short_url_routes'; - -export const createGetterRoute = ({ - router, - shortUrlLookup, - http, -}: { - router: IRouter; - shortUrlLookup: ShortUrlLookupService; - http: CoreSetup['http']; -}) => { - router.get( - { - path: getUrlPath('{urlId}'), - validate: { - params: schema.object({ urlId: schema.string() }), - }, - }, - router.handleLegacyErrors(async function (context, request, response) { - const url = await shortUrlLookup.getUrl(request.params.urlId, { - savedObjects: context.core.savedObjects.client, - }); - shortUrlAssertValid(url); - - return response.ok({ - body: { - url, - }, - }); - }) - ); -}; diff --git a/src/plugins/share/server/routes/goto.ts b/src/plugins/share/server/routes/goto.ts deleted file mode 100644 index f1d04c250d6b3..0000000000000 --- a/src/plugins/share/server/routes/goto.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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 { CoreSetup, IRouter } from 'kibana/server'; -import { schema } from '@kbn/config-schema'; -import { modifyUrl } from '@kbn/std'; - -import { shortUrlAssertValid } from './lib/short_url_assert_valid'; -import { ShortUrlLookupService } from './lib/short_url_lookup'; -import { getGotoPath } from '../../common/short_url_routes'; - -export const createGotoRoute = ({ - router, - shortUrlLookup, - http, -}: { - router: IRouter; - shortUrlLookup: ShortUrlLookupService; - http: CoreSetup['http']; -}) => { - http.resources.register( - { - path: getGotoPath('{urlId}'), - validate: { - params: schema.object({ urlId: schema.string() }), - }, - }, - router.handleLegacyErrors(async function (context, request, response) { - const url = await shortUrlLookup.getUrl(request.params.urlId, { - savedObjects: context.core.savedObjects.client, - }); - shortUrlAssertValid(url); - - const uiSettings = context.core.uiSettings.client; - const stateStoreInSessionStorage = await uiSettings.get('state:storeInSessionStorage'); - if (!stateStoreInSessionStorage) { - const basePath = http.basePath.get(request); - - const prependedUrl = modifyUrl(url, (parts) => { - if (!parts.hostname && parts.pathname && parts.pathname.startsWith('/')) { - parts.pathname = `${basePath}${parts.pathname}`; - } - }); - return response.redirected({ - headers: { - location: prependedUrl, - }, - }); - } - - return response.renderCoreApp(); - }) - ); -}; diff --git a/src/plugins/share/server/routes/lib/short_url_assert_valid.test.ts b/src/plugins/share/server/routes/lib/short_url_assert_valid.test.ts deleted file mode 100644 index e0e901c60637a..0000000000000 --- a/src/plugins/share/server/routes/lib/short_url_assert_valid.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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 { shortUrlAssertValid } from './short_url_assert_valid'; - -const PROTOCOL_ERROR = /^Short url targets cannot have a protocol/; -const HOSTNAME_ERROR = /^Short url targets cannot have a hostname/; -const PATH_ERROR = /^Short url target path must be in the format/; - -describe('shortUrlAssertValid()', () => { - const invalid = [ - ['protocol', 'http://localhost:5601/app/kibana', PROTOCOL_ERROR], - ['protocol', 'https://localhost:5601/app/kibana', PROTOCOL_ERROR], - ['protocol', 'mailto:foo@bar.net', PROTOCOL_ERROR], - ['protocol', 'javascript:alert("hi")', PROTOCOL_ERROR], // eslint-disable-line no-script-url - ['hostname', 'localhost/app/kibana', PATH_ERROR], // according to spec, this is not a valid URL -- you cannot specify a hostname without a protocol - ['hostname and port', 'local.host:5601/app/kibana', PROTOCOL_ERROR], // parser detects 'local.host' as the protocol - ['hostname and auth', 'user:pass@localhost.net/app/kibana', PROTOCOL_ERROR], // parser detects 'user' as the protocol - ['path traversal', '/app/../../not-kibana', PATH_ERROR], // fails because there are >2 path parts - ['path traversal', '/../not-kibana', PATH_ERROR], // fails because first path part is not 'app' - ['deep path', '/app/kibana/foo', PATH_ERROR], // fails because there are >2 path parts - ['deeper path', '/app/kibana/foo/bar', PATH_ERROR], // fails because there are >2 path parts - ['base path', '/base/app/kibana', PATH_ERROR], // fails because there are >2 path parts - ['path with an extra leading slash', '//foo/app/kibana', HOSTNAME_ERROR], // parser detects 'foo' as the hostname - ['path with an extra leading slash', '///app/kibana', HOSTNAME_ERROR], // parser detects '' as the hostname - ['path without app', '/foo/kibana', PATH_ERROR], // fails because first path part is not 'app' - ['path without appId', '/app/', PATH_ERROR], // fails because there is only one path part (leading and trailing slashes are trimmed) - ]; - - invalid.forEach(([desc, url, error]) => { - it(`fails when url has ${desc as string}`, () => { - expect(() => shortUrlAssertValid(url as string)).toThrowError(error); - }); - }); - - const valid = [ - '/app/kibana', - '/app/kibana/', // leading and trailing slashes are trimmed - '/app/monitoring#angular/route', - '/app/text#document-id', - '/app/some?with=query', - '/app/some?with=query#and-a-hash', - ]; - - valid.forEach((url) => { - it(`allows ${url}`, () => { - shortUrlAssertValid(url); - }); - }); -}); diff --git a/src/plugins/share/server/routes/lib/short_url_assert_valid.ts b/src/plugins/share/server/routes/lib/short_url_assert_valid.ts deleted file mode 100644 index 410cc2dff0452..0000000000000 --- a/src/plugins/share/server/routes/lib/short_url_assert_valid.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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 { parse } from 'url'; -import { trim } from 'lodash'; -import Boom from '@hapi/boom'; - -export function shortUrlAssertValid(url: string) { - const { protocol, hostname, pathname } = parse( - url, - false /* parseQueryString */, - true /* slashesDenoteHost */ - ); - - if (protocol !== null) { - throw Boom.notAcceptable(`Short url targets cannot have a protocol, found "${protocol}"`); - } - - if (hostname !== null) { - throw Boom.notAcceptable(`Short url targets cannot have a hostname, found "${hostname}"`); - } - - const pathnameParts = trim(pathname === null ? undefined : pathname, '/').split('/'); - if (pathnameParts.length !== 2 || pathnameParts[0] !== 'app' || !pathnameParts[1]) { - throw Boom.notAcceptable( - `Short url target path must be in the format "/app/{{appId}}", found "${pathname}"` - ); - } -} diff --git a/src/plugins/share/server/routes/lib/short_url_lookup.test.ts b/src/plugins/share/server/routes/lib/short_url_lookup.test.ts deleted file mode 100644 index 435ad5087d559..0000000000000 --- a/src/plugins/share/server/routes/lib/short_url_lookup.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* - * 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 { shortUrlLookupProvider, ShortUrlLookupService, UrlAttributes } from './short_url_lookup'; -import { SavedObjectsClientContract, SavedObject } from 'kibana/server'; - -import { savedObjectsClientMock, loggingSystemMock } from '../../../../../core/server/mocks'; - -describe('shortUrlLookupProvider', () => { - const ID = 'bf00ad16941fc51420f91a93428b27a0'; - const TYPE = 'url'; - const URL = 'http://elastic.co'; - - let savedObjects: jest.Mocked; - let deps: { savedObjects: SavedObjectsClientContract }; - let shortUrl: ShortUrlLookupService; - - beforeEach(() => { - savedObjects = savedObjectsClientMock.create(); - savedObjects.create.mockResolvedValue({ id: ID } as SavedObject); - deps = { savedObjects }; - shortUrl = shortUrlLookupProvider({ logger: loggingSystemMock.create().get() }); - }); - - describe('generateUrlId', () => { - it('returns the document id', async () => { - const id = await shortUrl.generateUrlId(URL, deps); - expect(id).toEqual(ID); - }); - - it('provides correct arguments to savedObjectsClient', async () => { - await shortUrl.generateUrlId(URL, { savedObjects }); - - expect(savedObjects.create).toHaveBeenCalledTimes(1); - const [type, attributes, options] = savedObjects.create.mock.calls[0]; - - expect(type).toEqual(TYPE); - expect(Object.keys(attributes as UrlAttributes).sort()).toEqual([ - 'accessCount', - 'accessDate', - 'createDate', - 'url', - ]); - expect((attributes as UrlAttributes).url).toEqual(URL); - expect(options!.id).toEqual(ID); - }); - - it('passes persists attributes', async () => { - await shortUrl.generateUrlId(URL, deps); - - expect(savedObjects.create).toHaveBeenCalledTimes(1); - const [type, attributes] = savedObjects.create.mock.calls[0]; - - expect(type).toEqual(TYPE); - expect(Object.keys(attributes as UrlAttributes).sort()).toEqual([ - 'accessCount', - 'accessDate', - 'createDate', - 'url', - ]); - expect((attributes as UrlAttributes).url).toEqual(URL); - }); - - it('gracefully handles version conflict', async () => { - const error = savedObjects.errors.decorateConflictError(new Error()); - savedObjects.create.mockImplementation(() => { - throw error; - }); - const id = await shortUrl.generateUrlId(URL, deps); - expect(id).toEqual(ID); - }); - }); - - describe('getUrl', () => { - beforeEach(() => { - const attributes = { accessCount: 2, url: URL }; - savedObjects.get.mockResolvedValue({ id: ID, attributes, type: 'url', references: [] }); - }); - - it('provides the ID to savedObjectsClient', async () => { - await shortUrl.getUrl(ID, { savedObjects }); - - expect(savedObjects.get).toHaveBeenCalledTimes(1); - expect(savedObjects.get).toHaveBeenCalledWith(TYPE, ID); - }); - - it('returns the url', async () => { - const response = await shortUrl.getUrl(ID, deps); - expect(response).toEqual(URL); - }); - - it('increments accessCount', async () => { - await shortUrl.getUrl(ID, { savedObjects }); - - expect(savedObjects.update).toHaveBeenCalledTimes(1); - - const [type, id, attributes] = savedObjects.update.mock.calls[0]; - - expect(type).toEqual(TYPE); - expect(id).toEqual(ID); - expect(Object.keys(attributes).sort()).toEqual(['accessCount', 'accessDate']); - expect((attributes as UrlAttributes).accessCount).toEqual(3); - }); - }); -}); diff --git a/src/plugins/share/server/routes/lib/short_url_lookup.ts b/src/plugins/share/server/routes/lib/short_url_lookup.ts deleted file mode 100644 index 505008cc77259..0000000000000 --- a/src/plugins/share/server/routes/lib/short_url_lookup.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * 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 crypto from 'crypto'; -import { get } from 'lodash'; - -import { Logger, SavedObject, SavedObjectsClientContract } from 'kibana/server'; - -export interface ShortUrlLookupService { - generateUrlId(url: string, deps: { savedObjects: SavedObjectsClientContract }): Promise; - getUrl(url: string, deps: { savedObjects: SavedObjectsClientContract }): Promise; -} - -export interface UrlAttributes { - url: string; - accessCount: number; - createDate: number; - accessDate: number; -} - -export function shortUrlLookupProvider({ logger }: { logger: Logger }): ShortUrlLookupService { - async function updateMetadata( - doc: SavedObject, - { savedObjects }: { savedObjects: SavedObjectsClientContract } - ) { - try { - await savedObjects.update('url', doc.id, { - accessDate: new Date().valueOf(), - accessCount: get(doc, 'attributes.accessCount', 0) + 1, - }); - } catch (error) { - logger.warn('Warning: Error updating url metadata'); - logger.warn(error); - // swallow errors. It isn't critical if there is no update. - } - } - - return { - async generateUrlId(url, { savedObjects }) { - const id = crypto.createHash('md5').update(url).digest('hex'); - const { isConflictError } = savedObjects.errors; - - try { - const doc = await savedObjects.create( - 'url', - { - url, - accessCount: 0, - createDate: new Date().valueOf(), - accessDate: new Date().valueOf(), - }, - { id } - ); - - return doc.id; - } catch (error) { - if (isConflictError(error)) { - return id; - } - - throw error; - } - }, - - async getUrl(id, { savedObjects }) { - const doc = await savedObjects.get('url', id); - updateMetadata(doc, { savedObjects }); - - return doc.attributes.url; - }, - }; -} diff --git a/src/plugins/share/server/routes/shorten_url.ts b/src/plugins/share/server/routes/shorten_url.ts deleted file mode 100644 index 82b62c9fae240..0000000000000 --- a/src/plugins/share/server/routes/shorten_url.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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 { IRouter } from 'kibana/server'; -import { schema } from '@kbn/config-schema'; - -import { shortUrlAssertValid } from './lib/short_url_assert_valid'; -import { ShortUrlLookupService } from './lib/short_url_lookup'; -import { CREATE_PATH } from '../../common/short_url_routes'; - -export const createShortenUrlRoute = ({ - shortUrlLookup, - router, -}: { - shortUrlLookup: ShortUrlLookupService; - router: IRouter; -}) => { - router.post( - { - path: CREATE_PATH, - validate: { - body: schema.object({ url: schema.string() }), - }, - }, - router.handleLegacyErrors(async function (context, request, response) { - shortUrlAssertValid(request.body.url); - const urlId = await shortUrlLookup.generateUrlId(request.body.url, { - savedObjects: context.core.savedObjects.client, - }); - return response.ok({ body: { urlId } }); - }) - ); -}; diff --git a/src/plugins/share/server/saved_objects/url.ts b/src/plugins/share/server/saved_objects/url.ts index cd5befac9a72a..6288e87f629f5 100644 --- a/src/plugins/share/server/saved_objects/url.ts +++ b/src/plugins/share/server/saved_objects/url.ts @@ -28,6 +28,14 @@ export const url: SavedObjectsType = { }, mappings: { properties: { + slug: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + }, + }, + }, accessCount: { type: 'long', }, @@ -37,6 +45,9 @@ export const url: SavedObjectsType = { createDate: { type: 'date', }, + // Legacy field - contains already pre-formatted final URL. + // This is here to support old saved objects that have this field. + // TODO: Remove this field and execute a migration to the new format. url: { type: 'text', fields: { @@ -46,6 +57,11 @@ export const url: SavedObjectsType = { }, }, }, + // Information needed to load and execute a locator. + locatorJSON: { + type: 'text', + index: false, + }, }, }, }; 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 new file mode 100644 index 0000000000000..35b513bebbc84 --- /dev/null +++ b/src/plugins/share/server/url_service/http/register_url_service_routes.ts @@ -0,0 +1,29 @@ +/* + * 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 { CoreSetup, IRouter } from 'kibana/server'; +import { ServerUrlService } from '../types'; +import { registerCreateRoute } from './short_urls/register_create_route'; +import { registerGetRoute } from './short_urls/register_get_route'; +import { registerDeleteRoute } from './short_urls/register_delete_route'; +import { registerResolveRoute } from './short_urls/register_resolve_route'; +import { registerGotoRoute } from './short_urls/register_goto_route'; +import { registerShortenUrlRoute } from './short_urls/register_shorten_url_route'; + +export const registerUrlServiceRoutes = ( + core: CoreSetup, + router: IRouter, + url: ServerUrlService +) => { + registerCreateRoute(router, url); + registerGetRoute(router, url); + registerDeleteRoute(router, url); + registerResolveRoute(router, url); + registerGotoRoute(router, core); + registerShortenUrlRoute(router, core); +}; 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 new file mode 100644 index 0000000000000..1d883bfa38086 --- /dev/null +++ b/src/plugins/share/server/url_service/http/short_urls/register_create_route.ts @@ -0,0 +1,66 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { IRouter } from 'kibana/server'; +import { ServerUrlService } from '../../types'; + +export const registerCreateRoute = (router: IRouter, url: ServerUrlService) => { + router.post( + { + path: '/api/short_url', + validate: { + body: schema.object({ + locatorId: schema.string({ + minLength: 1, + maxLength: 255, + }), + slug: schema.string({ + defaultValue: '', + minLength: 3, + maxLength: 255, + }), + humanReadableSlug: schema.boolean({ + defaultValue: false, + }), + params: schema.object({}, { unknowns: 'allow' }), + }), + }, + }, + router.handleLegacyErrors(async (ctx, req, res) => { + const savedObjects = ctx.core.savedObjects.client; + const shortUrls = url.shortUrls.get({ savedObjects }); + const { locatorId, params, slug, humanReadableSlug } = req.body; + const locator = url.locators.get(locatorId); + + if (!locator) { + return res.customError({ + statusCode: 409, + headers: { + 'content-type': 'application/json', + }, + body: 'Locator not found.', + }); + } + + const shortUrl = await shortUrls.create({ + locator, + params, + slug, + humanReadableSlug, + }); + + return res.ok({ + headers: { + 'content-type': 'application/json', + }, + body: shortUrl.data, + }); + }) + ); +}; diff --git a/src/plugins/share/server/url_service/http/short_urls/register_delete_route.ts b/src/plugins/share/server/url_service/http/short_urls/register_delete_route.ts new file mode 100644 index 0000000000000..2e241cddc15ca --- /dev/null +++ b/src/plugins/share/server/url_service/http/short_urls/register_delete_route.ts @@ -0,0 +1,41 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { IRouter } from 'kibana/server'; +import { ServerUrlService } from '../../types'; + +export const registerDeleteRoute = (router: IRouter, url: ServerUrlService) => { + router.delete( + { + path: '/api/short_url/{id}', + validate: { + params: schema.object({ + id: schema.string({ + minLength: 4, + maxLength: 128, + }), + }), + }, + }, + router.handleLegacyErrors(async (ctx, req, res) => { + const id = req.params.id; + const savedObjects = ctx.core.savedObjects.client; + const shortUrls = url.shortUrls.get({ savedObjects }); + + await shortUrls.delete(id); + + return res.ok({ + headers: { + 'content-type': 'application/json', + }, + body: 'null', + }); + }) + ); +}; diff --git a/src/plugins/share/server/url_service/http/short_urls/register_get_route.ts b/src/plugins/share/server/url_service/http/short_urls/register_get_route.ts new file mode 100644 index 0000000000000..149df5cf3999f --- /dev/null +++ b/src/plugins/share/server/url_service/http/short_urls/register_get_route.ts @@ -0,0 +1,40 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { IRouter } from 'kibana/server'; +import { ServerUrlService } from '../../types'; + +export const registerGetRoute = (router: IRouter, url: ServerUrlService) => { + router.get( + { + path: '/api/short_url/{id}', + validate: { + params: schema.object({ + id: schema.string({ + minLength: 4, + maxLength: 128, + }), + }), + }, + }, + router.handleLegacyErrors(async (ctx, req, res) => { + const id = req.params.id; + const savedObjects = ctx.core.savedObjects.client; + const shortUrls = url.shortUrls.get({ savedObjects }); + const shortUrl = await shortUrls.get(id); + + return res.ok({ + headers: { + 'content-type': 'application/json', + }, + body: shortUrl.data, + }); + }) + ); +}; diff --git a/src/plugins/share/server/url_service/http/short_urls/register_goto_route.ts b/src/plugins/share/server/url_service/http/short_urls/register_goto_route.ts new file mode 100644 index 0000000000000..679d5ad671700 --- /dev/null +++ b/src/plugins/share/server/url_service/http/short_urls/register_goto_route.ts @@ -0,0 +1,33 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { CoreSetup, IRouter } from 'kibana/server'; + +/** + * This endpoint maintains the legacy /goto/ route. It loads the + * /app/goto/ app which handles the redirection. + */ +export const registerGotoRoute = (router: IRouter, core: CoreSetup) => { + core.http.resources.register( + { + path: '/goto/{id}', + validate: { + params: schema.object({ + id: schema.string({ + minLength: 4, + maxLength: 128, + }), + }), + }, + }, + router.handleLegacyErrors(async (ctx, req, res) => { + return res.renderCoreApp(); + }) + ); +}; diff --git a/src/plugins/share/server/url_service/http/short_urls/register_resolve_route.ts b/src/plugins/share/server/url_service/http/short_urls/register_resolve_route.ts new file mode 100644 index 0000000000000..5093b12f5450f --- /dev/null +++ b/src/plugins/share/server/url_service/http/short_urls/register_resolve_route.ts @@ -0,0 +1,40 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { IRouter } from 'kibana/server'; +import { ServerUrlService } from '../../types'; + +export const registerResolveRoute = (router: IRouter, url: ServerUrlService) => { + router.get( + { + path: '/api/short_url/_slug/{slug}', + validate: { + params: schema.object({ + slug: schema.string({ + minLength: 4, + maxLength: 128, + }), + }), + }, + }, + router.handleLegacyErrors(async (ctx, req, res) => { + const slug = req.params.slug; + const savedObjects = ctx.core.savedObjects.client; + const shortUrls = url.shortUrls.get({ savedObjects }); + const shortUrl = await shortUrls.resolve(slug); + + return res.ok({ + headers: { + 'content-type': 'application/json', + }, + body: shortUrl.data, + }); + }) + ); +}; diff --git a/src/plugins/share/server/url_service/http/short_urls/register_shorten_url_route.ts b/src/plugins/share/server/url_service/http/short_urls/register_shorten_url_route.ts new file mode 100644 index 0000000000000..19fa9339e9022 --- /dev/null +++ b/src/plugins/share/server/url_service/http/short_urls/register_shorten_url_route.ts @@ -0,0 +1,23 @@ +/* + * 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 { CoreSetup, IRouter } from 'kibana/server'; + +export const registerShortenUrlRoute = (router: IRouter, core: CoreSetup) => { + core.http.resources.register( + { + path: '/api/shorten_url', + validate: {}, + }, + router.handleLegacyErrors(async (ctx, req, res) => { + return res.badRequest({ + body: 'This endpoint is no longer supported. Please use the new URL shortening service.', + }); + }) + ); +}; diff --git a/src/plugins/share/server/url_service/index.ts b/src/plugins/share/server/url_service/index.ts new file mode 100644 index 0000000000000..068a5289d42ed --- /dev/null +++ b/src/plugins/share/server/url_service/index.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export * from './types'; +export * from './short_urls'; diff --git a/src/plugins/share/server/url_service/short_urls/index.ts b/src/plugins/share/server/url_service/short_urls/index.ts new file mode 100644 index 0000000000000..cbb5fa083566d --- /dev/null +++ b/src/plugins/share/server/url_service/short_urls/index.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export * from './types'; +export * from './short_url_client'; +export * from './short_url_client_factory'; diff --git a/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts b/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts new file mode 100644 index 0000000000000..ac684eb03a9d5 --- /dev/null +++ b/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts @@ -0,0 +1,180 @@ +/* + * 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 { ServerShortUrlClientFactory } from './short_url_client_factory'; +import { UrlService } from '../../../common/url_service'; +import { LegacyShortUrlLocatorDefinition } from '../../../common/url_service/locators/legacy_short_url_locator'; +import { MemoryShortUrlStorage } from './storage/memory_short_url_storage'; + +const setup = () => { + const currentVersion = '1.2.3'; + const service = new UrlService({ + getUrl: () => { + throw new Error('Not implemented.'); + }, + navigate: () => { + throw new Error('Not implemented.'); + }, + shortUrls: new ServerShortUrlClientFactory({ + currentVersion, + }), + }); + const definition = new LegacyShortUrlLocatorDefinition(); + const locator = service.locators.create(definition); + const storage = new MemoryShortUrlStorage(); + const client = service.shortUrls.get({ storage }); + + return { + service, + client, + storage, + locator, + definition, + currentVersion, + }; +}; + +describe('ServerShortUrlClient', () => { + describe('.create()', () => { + test('can create a short URL', async () => { + const { client, locator, currentVersion } = setup(); + const shortUrl = await client.create({ + locator, + params: { + url: '/app/test#foo/bar/baz', + }, + }); + + expect(shortUrl).toMatchObject({ + data: { + accessCount: 0, + accessDate: expect.any(Number), + createDate: expect.any(Number), + slug: expect.any(String), + locator: { + id: locator.id, + version: currentVersion, + state: { + url: '/app/test#foo/bar/baz', + }, + }, + id: expect.any(String), + }, + }); + }); + }); + + describe('.resolve()', () => { + test('can get short URL by its slug', async () => { + const { client, locator } = setup(); + const shortUrl1 = await client.create({ + locator, + params: { + url: '/app/test#foo/bar/baz', + }, + }); + const shortUrl2 = await client.resolve(shortUrl1.data.slug); + + expect(shortUrl2.data).toMatchObject(shortUrl1.data); + }); + + test('can create short URL with custom slug', async () => { + const { client, locator } = setup(); + await client.create({ + locator, + params: { + url: '/app/test#foo/bar/baz', + }, + }); + const shortUrl1 = await client.create({ + locator, + slug: 'foo-bar', + params: { + url: '/app/test#foo/bar/baz', + }, + }); + const shortUrl2 = await client.resolve('foo-bar'); + + expect(shortUrl2.data).toMatchObject(shortUrl1.data); + }); + + test('cannot create short URL with the same slug', async () => { + const { client, locator } = setup(); + await client.create({ + locator, + slug: 'lala', + params: { + url: '/app/test#foo/bar/baz', + }, + }); + + await expect( + client.create({ + locator, + slug: 'lala', + params: { + url: '/app/test#foo/bar/baz', + }, + }) + ).rejects.toThrowError(new Error(`Slug "lala" already exists.`)); + }); + + test('can automatically generate human-readable slug', async () => { + const { client, locator } = setup(); + const shortUrl = await client.create({ + locator, + humanReadableSlug: true, + params: { + url: '/app/test#foo/bar/baz', + }, + }); + + expect(shortUrl.data.slug.split('-').length).toBe(3); + }); + }); + + describe('.get()', () => { + test('can fetch created short URL', async () => { + const { client, locator } = setup(); + const shortUrl1 = await client.create({ + locator, + params: { + url: '/app/test#foo/bar/baz', + }, + }); + const shortUrl2 = await client.get(shortUrl1.data.id); + + expect(shortUrl2.data).toMatchObject(shortUrl1.data); + }); + + test('throws when fetching non-existing short URL', async () => { + const { client } = setup(); + + await expect(() => client.get('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')).rejects.toThrowError( + new Error(`No short url with id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"`) + ); + }); + }); + + describe('.delete()', () => { + test('can delete an existing short URL', async () => { + const { client, locator } = setup(); + const shortUrl1 = await client.create({ + locator, + params: { + url: '/app/test#foo/bar/baz', + }, + }); + await client.delete(shortUrl1.data.id); + + await expect(() => client.get(shortUrl1.data.id)).rejects.toThrowError( + new Error(`No short url with id "${shortUrl1.data.id}"`) + ); + }); + }); +}); diff --git a/src/plugins/share/server/url_service/short_urls/short_url_client.ts b/src/plugins/share/server/url_service/short_urls/short_url_client.ts new file mode 100644 index 0000000000000..caaa76bef172d --- /dev/null +++ b/src/plugins/share/server/url_service/short_urls/short_url_client.ts @@ -0,0 +1,107 @@ +/* + * 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 type { SerializableRecord } from '@kbn/utility-types'; +import { generateSlug } from 'random-word-slugs'; +import type { IShortUrlClient, ShortUrl, ShortUrlCreateParams } from '../../../common/url_service'; +import type { ShortUrlStorage } from './types'; +import { validateSlug } from './util'; + +const defaultAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + +function randomStr(length: number, alphabet = defaultAlphabet) { + let str = ''; + const alphabetLength = alphabet.length; + for (let i = 0; i < length; i++) { + str += alphabet.charAt(Math.floor(Math.random() * alphabetLength)); + } + return str; +} + +/** + * Dependencies of the Short URL Client. + */ +export interface ServerShortUrlClientDependencies { + /** + * Current version of Kibana, e.g. 7.15.0. + */ + currentVersion: string; + + /** + * Storage provider for short URLs. + */ + storage: ShortUrlStorage; +} + +export class ServerShortUrlClient implements IShortUrlClient { + constructor(private readonly dependencies: ServerShortUrlClientDependencies) {} + + public async create

({ + locator, + params, + slug = '', + humanReadableSlug = false, + }: ShortUrlCreateParams

): Promise> { + if (slug) { + validateSlug(slug); + } + + if (!slug) { + slug = humanReadableSlug ? generateSlug() : randomStr(4); + } + + const { storage, currentVersion } = this.dependencies; + + if (slug) { + const isSlugTaken = await storage.exists(slug); + if (isSlugTaken) { + throw new Error(`Slug "${slug}" already exists.`); + } + } + + const now = Date.now(); + const data = await storage.create({ + accessCount: 0, + accessDate: now, + createDate: now, + slug, + locator: { + id: locator.id, + version: currentVersion, + state: params, + }, + }); + + return { + data, + }; + } + + public async get(id: string): Promise { + const { storage } = this.dependencies; + const data = await storage.getById(id); + + return { + data, + }; + } + + public async delete(id: string): Promise { + const { storage } = this.dependencies; + await storage.delete(id); + } + + public async resolve(slug: string): Promise { + const { storage } = this.dependencies; + const data = await storage.getBySlug(slug); + + return { + data, + }; + } +} diff --git a/src/plugins/share/server/url_service/short_urls/short_url_client_factory.ts b/src/plugins/share/server/url_service/short_urls/short_url_client_factory.ts new file mode 100644 index 0000000000000..696233b7a1ca5 --- /dev/null +++ b/src/plugins/share/server/url_service/short_urls/short_url_client_factory.ts @@ -0,0 +1,49 @@ +/* + * 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 { SavedObjectsClientContract } from 'kibana/server'; +import { ShortUrlStorage } from './types'; +import type { IShortUrlClientFactory } from '../../../common/url_service'; +import { ServerShortUrlClient } from './short_url_client'; +import { SavedObjectShortUrlStorage } from './storage/saved_object_short_url_storage'; + +/** + * Dependencies of the Short URL Client factory. + */ +export interface ServerShortUrlClientFactoryDependencies { + /** + * Current version of Kibana, e.g. 7.15.0. + */ + currentVersion: string; +} + +export interface ServerShortUrlClientFactoryCreateParams { + savedObjects?: SavedObjectsClientContract; + storage?: ShortUrlStorage; +} + +export class ServerShortUrlClientFactory + implements IShortUrlClientFactory +{ + constructor(private readonly dependencies: ServerShortUrlClientFactoryDependencies) {} + + public get(params: ServerShortUrlClientFactoryCreateParams): ServerShortUrlClient { + const storage = + params.storage ?? + new SavedObjectShortUrlStorage({ + savedObjects: params.savedObjects!, + savedObjectType: 'url', + }); + const client = new ServerShortUrlClient({ + storage, + currentVersion: this.dependencies.currentVersion, + }); + + return client; + } +} diff --git a/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.test.ts b/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.test.ts new file mode 100644 index 0000000000000..d178e0b81786c --- /dev/null +++ b/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.test.ts @@ -0,0 +1,177 @@ +/* + * 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 { of } from 'src/plugins/kibana_utils/common'; +import { MemoryShortUrlStorage } from './memory_short_url_storage'; + +describe('.create()', () => { + test('can create a new short URL', async () => { + const storage = new MemoryShortUrlStorage(); + const now = Date.now(); + const url1 = await storage.create({ + accessCount: 0, + createDate: now, + accessDate: now, + locator: { + id: 'TEST_LOCATOR', + version: '7.11', + state: { + foo: 'bar', + }, + }, + slug: 'test-slug', + }); + + expect(url1.accessCount).toBe(0); + expect(url1.createDate).toBe(now); + expect(url1.accessDate).toBe(now); + expect(url1.slug).toBe('test-slug'); + expect(url1.locator).toEqual({ + id: 'TEST_LOCATOR', + version: '7.11', + state: { + foo: 'bar', + }, + }); + }); +}); + +describe('.getById()', () => { + test('can fetch by ID a newly created short URL', async () => { + const storage = new MemoryShortUrlStorage(); + const now = Date.now(); + const url1 = await storage.create({ + accessCount: 0, + createDate: now, + accessDate: now, + locator: { + id: 'TEST_LOCATOR', + version: '7.11', + state: { + foo: 'bar', + }, + }, + slug: 'test-slug', + }); + const url2 = await storage.getById(url1.id); + + expect(url2.accessCount).toBe(0); + expect(url1.createDate).toBe(now); + expect(url1.accessDate).toBe(now); + expect(url2.slug).toBe('test-slug'); + expect(url2.locator).toEqual({ + id: 'TEST_LOCATOR', + version: '7.11', + state: { + foo: 'bar', + }, + }); + }); + + test('throws when URL does not exist', async () => { + const storage = new MemoryShortUrlStorage(); + const now = Date.now(); + await storage.create({ + accessCount: 0, + createDate: now, + accessDate: now, + locator: { + id: 'TEST_LOCATOR', + version: '7.11', + state: { + foo: 'bar', + }, + }, + slug: 'test-slug', + }); + const [, error] = await of(storage.getById('DOES_NOT_EXIST')); + + expect(error).toBeInstanceOf(Error); + }); +}); + +describe('.getBySlug()', () => { + test('can fetch by slug a newly created short URL', async () => { + const storage = new MemoryShortUrlStorage(); + const now = Date.now(); + const url1 = await storage.create({ + accessCount: 0, + createDate: now, + accessDate: now, + locator: { + id: 'TEST_LOCATOR', + version: '7.11', + state: { + foo: 'bar', + }, + }, + slug: 'test-slug', + }); + const url2 = await storage.getBySlug('test-slug'); + + expect(url2.accessCount).toBe(0); + expect(url1.createDate).toBe(now); + expect(url1.accessDate).toBe(now); + expect(url2.slug).toBe('test-slug'); + expect(url2.locator).toEqual({ + id: 'TEST_LOCATOR', + version: '7.11', + state: { + foo: 'bar', + }, + }); + }); + + test('throws when URL does not exist', async () => { + const storage = new MemoryShortUrlStorage(); + const now = Date.now(); + await storage.create({ + accessCount: 0, + createDate: now, + accessDate: now, + locator: { + id: 'TEST_LOCATOR', + version: '7.11', + state: { + foo: 'bar', + }, + }, + slug: 'test-slug', + }); + const [, error] = await of(storage.getBySlug('DOES_NOT_EXIST')); + + expect(error).toBeInstanceOf(Error); + }); +}); + +describe('.delete()', () => { + test('can delete a newly created URL', async () => { + const storage = new MemoryShortUrlStorage(); + const now = Date.now(); + const url1 = await storage.create({ + accessCount: 0, + createDate: now, + accessDate: now, + locator: { + id: 'TEST_LOCATOR', + version: '7.11', + state: { + foo: 'bar', + }, + }, + slug: 'test-slug', + }); + + const [, error1] = await of(storage.getById(url1.id)); + await storage.delete(url1.id); + const [, error2] = await of(storage.getById(url1.id)); + + expect(error1).toBe(undefined); + expect(error2).toBeInstanceOf(Error); + }); +}); diff --git a/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.ts b/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.ts new file mode 100644 index 0000000000000..40d76a91154ba --- /dev/null +++ b/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.ts @@ -0,0 +1,58 @@ +/* + * 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 { v4 as uuidv4 } from 'uuid'; +import type { SerializableRecord } from '@kbn/utility-types'; +import { ShortUrlData } from 'src/plugins/share/common/url_service/short_urls/types'; +import { ShortUrlStorage } from '../types'; + +export class MemoryShortUrlStorage implements ShortUrlStorage { + private urls = new Map(); + + public async create

( + data: Omit, 'id'> + ): Promise> { + const id = uuidv4(); + const url: ShortUrlData

= { ...data, id }; + this.urls.set(id, url); + return url; + } + + public async getById

( + id: string + ): Promise> { + if (!this.urls.has(id)) { + throw new Error(`No short url with id "${id}"`); + } + return this.urls.get(id)! as ShortUrlData

; + } + + public async getBySlug

( + slug: string + ): Promise> { + for (const url of this.urls.values()) { + if (url.slug === slug) { + return url as ShortUrlData

; + } + } + throw new Error(`No short url with slug "${slug}".`); + } + + public async exists(slug: string): Promise { + for (const url of this.urls.values()) { + if (url.slug === slug) { + return true; + } + } + return false; + } + + public async delete(id: string): Promise { + this.urls.delete(id); + } +} diff --git a/src/plugins/share/server/url_service/short_urls/storage/saved_object_short_url_storage.ts b/src/plugins/share/server/url_service/short_urls/storage/saved_object_short_url_storage.ts new file mode 100644 index 0000000000000..c66db6d82cdbd --- /dev/null +++ b/src/plugins/share/server/url_service/short_urls/storage/saved_object_short_url_storage.ts @@ -0,0 +1,164 @@ +/* + * 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 type { SerializableRecord } from '@kbn/utility-types'; +import { SavedObject, SavedObjectsClientContract } from 'kibana/server'; +import { LEGACY_SHORT_URL_LOCATOR_ID } from '../../../../common/url_service/locators/legacy_short_url_locator'; +import { ShortUrlData } from '../../../../common/url_service/short_urls/types'; +import { ShortUrlStorage } from '../types'; +import { escapeSearchReservedChars } from '../util'; + +export type ShortUrlSavedObject = SavedObject; + +/** + * Fields that stored in the short url saved object. + */ +export interface ShortUrlSavedObjectAttributes { + /** + * The slug of the short URL, the part after the `/` in the URL. + */ + readonly slug?: string; + + /** + * Number of times the short URL has been resolved. + */ + readonly accessCount: number; + + /** + * The timestamp of the last time the short URL was resolved. + */ + readonly accessDate: number; + + /** + * The timestamp when the short URL was created. + */ + readonly createDate: number; + + /** + * Serialized locator state. + */ + readonly locatorJSON: string; + + /** + * Legacy field - was used in old short URL versions. This field will + * be removed in the future by a migration. + * + * @deprecated + */ + readonly url: string; +} + +const createShortUrlData =

( + savedObject: ShortUrlSavedObject +): ShortUrlData

=> { + const attributes = savedObject.attributes; + + if (!!attributes.url) { + const { url, ...rest } = attributes; + const state = { url } as unknown as P; + + return { + id: savedObject.id, + slug: savedObject.id, + locator: { + id: LEGACY_SHORT_URL_LOCATOR_ID, + version: '7.15.0', + state, + }, + ...rest, + } as ShortUrlData

; + } + + const { locatorJSON, ...rest } = attributes; + const locator = JSON.parse(locatorJSON) as ShortUrlData

['locator']; + + return { + id: savedObject.id, + locator, + ...rest, + } as ShortUrlData

; +}; + +const createAttributes =

( + data: Omit, 'id'> +): ShortUrlSavedObjectAttributes => { + const { locator, ...rest } = data; + const attributes: ShortUrlSavedObjectAttributes = { + ...rest, + locatorJSON: JSON.stringify(locator), + url: '', + }; + + return attributes; +}; + +export interface SavedObjectShortUrlStorageDependencies { + savedObjectType: string; + savedObjects: SavedObjectsClientContract; +} + +export class SavedObjectShortUrlStorage implements ShortUrlStorage { + constructor(private readonly dependencies: SavedObjectShortUrlStorageDependencies) {} + + public async create

( + data: Omit, 'id'> + ): Promise> { + const { savedObjects, savedObjectType } = this.dependencies; + const attributes = createAttributes(data); + + const savedObject = await savedObjects.create(savedObjectType, attributes, { + refresh: true, + }); + + return createShortUrlData

(savedObject); + } + + public async getById

( + id: string + ): Promise> { + const { savedObjects, savedObjectType } = this.dependencies; + const savedObject = await savedObjects.get(savedObjectType, id); + + return createShortUrlData

(savedObject); + } + + public async getBySlug

( + slug: string + ): Promise> { + const { savedObjects } = this.dependencies; + const search = `(attributes.slug:"${escapeSearchReservedChars(slug)}")`; + const result = await savedObjects.find({ + type: this.dependencies.savedObjectType, + search, + }); + + if (result.saved_objects.length !== 1) { + throw new Error('not found'); + } + + const savedObject = result.saved_objects[0] as ShortUrlSavedObject; + + return createShortUrlData

(savedObject); + } + + public async exists(slug: string): Promise { + const { savedObjects } = this.dependencies; + const search = `(attributes.slug:"${escapeSearchReservedChars(slug)}")`; + const result = await savedObjects.find({ + type: this.dependencies.savedObjectType, + search, + }); + + return result.saved_objects.length > 0; + } + + public async delete(id: string): Promise { + const { savedObjects, savedObjectType } = this.dependencies; + await savedObjects.delete(savedObjectType, id); + } +} diff --git a/src/plugins/share/server/url_service/short_urls/types.ts b/src/plugins/share/server/url_service/short_urls/types.ts new file mode 100644 index 0000000000000..7aab70ca49519 --- /dev/null +++ b/src/plugins/share/server/url_service/short_urls/types.ts @@ -0,0 +1,44 @@ +/* + * 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 type { SerializableRecord } from '@kbn/utility-types'; +import { ShortUrlData } from '../../../common/url_service/short_urls/types'; + +/** + * Interface used for persisting short URLs. + */ +export interface ShortUrlStorage { + /** + * Create and store a new short URL entry. + */ + create

( + data: Omit, 'id'> + ): Promise>; + + /** + * Fetch a short URL entry by ID. + */ + getById

(id: string): Promise>; + + /** + * Fetch a short URL entry by slug. + */ + getBySlug

( + slug: string + ): Promise>; + + /** + * Checks if a short URL exists by slug. + */ + exists(slug: string): Promise; + + /** + * Delete an existing short URL entry. + */ + delete(id: string): Promise; +} diff --git a/src/plugins/share/server/url_service/short_urls/util.test.ts b/src/plugins/share/server/url_service/short_urls/util.test.ts new file mode 100644 index 0000000000000..44953152a3f78 --- /dev/null +++ b/src/plugins/share/server/url_service/short_urls/util.test.ts @@ -0,0 +1,69 @@ +/* + * 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 { escapeSearchReservedChars, validateSlug } from './util'; + +describe('escapeSearchReservedChars', () => { + it('should escape search reserved chars', () => { + expect(escapeSearchReservedChars('+')).toEqual('\\+'); + expect(escapeSearchReservedChars('-')).toEqual('\\-'); + expect(escapeSearchReservedChars('!')).toEqual('\\!'); + expect(escapeSearchReservedChars('(')).toEqual('\\('); + expect(escapeSearchReservedChars(')')).toEqual('\\)'); + expect(escapeSearchReservedChars('*')).toEqual('\\*'); + expect(escapeSearchReservedChars('~')).toEqual('\\~'); + expect(escapeSearchReservedChars('^')).toEqual('\\^'); + expect(escapeSearchReservedChars('|')).toEqual('\\|'); + expect(escapeSearchReservedChars('[')).toEqual('\\['); + expect(escapeSearchReservedChars(']')).toEqual('\\]'); + expect(escapeSearchReservedChars('{')).toEqual('\\{'); + expect(escapeSearchReservedChars('}')).toEqual('\\}'); + expect(escapeSearchReservedChars('"')).toEqual('\\"'); + }); + + it('escapes short URL slugs', () => { + expect(escapeSearchReservedChars('test-slug-123456789')).toEqual('test\\-slug\\-123456789'); + expect(escapeSearchReservedChars('my-dashboard-link')).toEqual('my\\-dashboard\\-link'); + expect(escapeSearchReservedChars('link-v1.0.0')).toEqual('link\\-v1.0.0'); + expect(escapeSearchReservedChars('simple_link')).toEqual('simple_link'); + }); +}); + +describe('validateSlug', () => { + it('validates slugs that contain [a-zA-Z0-9.-_] chars', () => { + validateSlug('asdf'); + validateSlug('asdf-asdf'); + validateSlug('asdf-asdf-333'); + validateSlug('my-custom-slug'); + validateSlug('my.slug'); + validateSlug('my_super-custom.slug'); + }); + + it('throws on slugs which contain invalid characters', () => { + expect(() => validateSlug('hello-tom&herry')).toThrowErrorMatchingInlineSnapshot( + `"Invalid [slug = hello-tom&herry]."` + ); + expect(() => validateSlug('foo(bar)')).toThrowErrorMatchingInlineSnapshot( + `"Invalid [slug = foo(bar)]."` + ); + }); + + it('throws if slug is shorter than 3 chars', () => { + expect(() => validateSlug('ab')).toThrowErrorMatchingInlineSnapshot(`"Invalid [slug = ab]."`); + }); + + it('throws if slug is longer than 255 chars', () => { + expect(() => + validateSlug( + 'aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa' + ) + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid [slug = aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa]."` + ); + }); +}); diff --git a/src/plugins/share/server/url_service/short_urls/util.ts b/src/plugins/share/server/url_service/short_urls/util.ts new file mode 100644 index 0000000000000..d09af43a179f6 --- /dev/null +++ b/src/plugins/share/server/url_service/short_urls/util.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +/** + * This function escapes reserved characters as listed here: + * https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#_reserved_characters + */ +export const escapeSearchReservedChars = (str: string) => { + return str.replace(/[-=&|!{}()\[\]^"~*?:\\\/\+]+/g, '\\$&'); +}; + +/** + * Allows only characters in slug that can appear as a part of a URL. + */ +export const validateSlug = (slug: string) => { + const regex = /^[a-zA-Z0-9\.\-\_]{3,255}$/; + if (!regex.test(slug)) { + throw new Error(`Invalid [slug = ${slug}].`); + } +}; diff --git a/src/plugins/share/server/url_service/types.ts b/src/plugins/share/server/url_service/types.ts new file mode 100644 index 0000000000000..fe517d46e59c3 --- /dev/null +++ b/src/plugins/share/server/url_service/types.ts @@ -0,0 +1,12 @@ +/* + * 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 { UrlService } from '../../common/url_service'; +import { ServerShortUrlClientFactoryCreateParams } from './short_urls'; + +export type ServerUrlService = UrlService; diff --git a/test/api_integration/apis/index.ts b/test/api_integration/apis/index.ts index a6b8b746f68cf..bdbb9c0a1fae7 100644 --- a/test/api_integration/apis/index.ts +++ b/test/api_integration/apis/index.ts @@ -22,7 +22,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./saved_objects')); loadTestFile(require.resolve('./scripts')); loadTestFile(require.resolve('./search')); - loadTestFile(require.resolve('./shorten')); + loadTestFile(require.resolve('./short_url')); loadTestFile(require.resolve('./suggestions')); loadTestFile(require.resolve('./status')); loadTestFile(require.resolve('./stats')); diff --git a/test/api_integration/apis/short_url/create_short_url/index.ts b/test/api_integration/apis/short_url/create_short_url/index.ts new file mode 100644 index 0000000000000..88a4fdd859a40 --- /dev/null +++ b/test/api_integration/apis/short_url/create_short_url/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('create_short_url', () => { + loadTestFile(require.resolve('./validation')); + loadTestFile(require.resolve('./main')); + }); +} diff --git a/test/api_integration/apis/short_url/create_short_url/main.ts b/test/api_integration/apis/short_url/create_short_url/main.ts new file mode 100644 index 0000000000000..a01a23906a337 --- /dev/null +++ b/test/api_integration/apis/short_url/create_short_url/main.ts @@ -0,0 +1,139 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('main', () => { + it('can create a short URL with just locator data', async () => { + const response = await supertest.post('/api/short_url').send({ + locatorId: 'LEGACY_SHORT_URL_LOCATOR', + params: {}, + }); + + expect(response.status).to.be(200); + expect(typeof response.body).to.be('object'); + expect(typeof response.body.id).to.be('string'); + expect(typeof response.body.locator).to.be('object'); + expect(response.body.locator.id).to.be('LEGACY_SHORT_URL_LOCATOR'); + expect(typeof response.body.locator.version).to.be('string'); + expect(response.body.locator.state).to.eql({}); + expect(response.body.accessCount).to.be(0); + expect(typeof response.body.accessDate).to.be('number'); + expect(typeof response.body.createDate).to.be('number'); + expect(typeof response.body.slug).to.be('string'); + expect(response.body.url).to.be(''); + }); + + it('can create a short URL with locator params', async () => { + const response = await supertest.post('/api/short_url').send({ + locatorId: 'LEGACY_SHORT_URL_LOCATOR', + params: { + url: '/foo/bar', + }, + }); + + expect(response.status).to.be(200); + expect(typeof response.body).to.be('object'); + expect(typeof response.body.id).to.be('string'); + expect(typeof response.body.locator).to.be('object'); + expect(response.body.locator.id).to.be('LEGACY_SHORT_URL_LOCATOR'); + expect(typeof response.body.locator.version).to.be('string'); + expect(response.body.locator.state).to.eql({ + url: '/foo/bar', + }); + expect(response.body.accessCount).to.be(0); + expect(typeof response.body.accessDate).to.be('number'); + expect(typeof response.body.createDate).to.be('number'); + expect(typeof response.body.slug).to.be('string'); + expect(response.body.url).to.be(''); + }); + + describe('short_url slugs', () => { + it('generates at least 4 character slug by default', async () => { + const response = await supertest.post('/api/short_url').send({ + locatorId: 'LEGACY_SHORT_URL_LOCATOR', + params: {}, + }); + + expect(response.status).to.be(200); + expect(typeof response.body.slug).to.be('string'); + expect(response.body.slug.length > 3).to.be(true); + expect(response.body.url).to.be(''); + }); + + it('can generate a human-readable slug, composed of three words', async () => { + const response = await supertest.post('/api/short_url').send({ + locatorId: 'LEGACY_SHORT_URL_LOCATOR', + params: {}, + humanReadableSlug: true, + }); + + expect(response.status).to.be(200); + expect(typeof response.body.slug).to.be('string'); + const words = response.body.slug.split('-'); + expect(words.length).to.be(3); + for (const word of words) { + expect(word.length > 0).to.be(true); + } + }); + + it('can create a short URL with custom slug', async () => { + const rnd = Math.round(Math.random() * 1e6) + 1; + const slug = 'test-slug-' + Date.now() + '-' + rnd; + const response = await supertest.post('/api/short_url').send({ + locatorId: 'LEGACY_SHORT_URL_LOCATOR', + params: { + url: '/foo/bar', + }, + slug, + }); + + expect(response.status).to.be(200); + expect(typeof response.body).to.be('object'); + expect(typeof response.body.id).to.be('string'); + expect(typeof response.body.locator).to.be('object'); + expect(response.body.locator.id).to.be('LEGACY_SHORT_URL_LOCATOR'); + expect(typeof response.body.locator.version).to.be('string'); + expect(response.body.locator.state).to.eql({ + url: '/foo/bar', + }); + expect(response.body.accessCount).to.be(0); + expect(typeof response.body.accessDate).to.be('number'); + expect(typeof response.body.createDate).to.be('number'); + expect(response.body.slug).to.be(slug); + expect(response.body.url).to.be(''); + }); + + it('cannot create a short URL with the same slug', async () => { + const rnd = Math.round(Math.random() * 1e6) + 1; + const slug = 'test-slug-' + Date.now() + '-' + rnd; + const response1 = await supertest.post('/api/short_url').send({ + locatorId: 'LEGACY_SHORT_URL_LOCATOR', + params: { + url: '/foo/bar', + }, + slug, + }); + const response2 = await supertest.post('/api/short_url').send({ + locatorId: 'LEGACY_SHORT_URL_LOCATOR', + params: { + url: '/foo/bar', + }, + slug, + }); + + expect(response1.status === 200).to.be(true); + expect(response2.status >= 400).to.be(true); + }); + }); + }); +} diff --git a/test/api_integration/apis/short_url/create_short_url/validation.ts b/test/api_integration/apis/short_url/create_short_url/validation.ts new file mode 100644 index 0000000000000..8cba7970926e1 --- /dev/null +++ b/test/api_integration/apis/short_url/create_short_url/validation.ts @@ -0,0 +1,68 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('validation', () => { + it('returns error when no data is provided in POST payload', async () => { + const response = await supertest.post('/api/short_url'); + + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body]: expected a plain object value, but found [null] instead.' + ); + }); + + it('returns error when locator ID is not provided', async () => { + const response = await supertest.post('/api/short_url').send({ + params: {}, + }); + + expect(response.status).to.be(400); + }); + + it('returns error when locator is not found', async () => { + const response = await supertest.post('/api/short_url').send({ + locatorId: 'LEGACY_SHORT_URL_LOCATOR-NOT_FOUND', + params: {}, + }); + + expect(response.status).to.be(409); + expect(response.body.statusCode).to.be(409); + expect(response.body.error).to.be('Conflict'); + expect(response.body.message).to.be('Locator not found.'); + }); + + it('returns error when slug is too short', async () => { + const response = await supertest.post('/api/short_url').send({ + locatorId: 'LEGACY_SHORT_URL_LOCATOR', + params: {}, + slug: 'a', + }); + + expect(response.status).to.be(400); + }); + + it('returns error on invalid character in slug', async () => { + const response = await supertest.post('/api/short_url').send({ + locatorId: 'LEGACY_SHORT_URL_LOCATOR', + params: { + url: '/foo/bar', + }, + slug: 'pipe|is-not-allowed', + }); + + expect(response.status >= 400).to.be(true); + }); + }); +} diff --git a/test/api_integration/apis/short_url/delete_short_url/index.ts b/test/api_integration/apis/short_url/delete_short_url/index.ts new file mode 100644 index 0000000000000..608abf391d157 --- /dev/null +++ b/test/api_integration/apis/short_url/delete_short_url/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('delete_short_url', () => { + loadTestFile(require.resolve('./validation')); + loadTestFile(require.resolve('./main')); + }); +} diff --git a/test/api_integration/apis/short_url/delete_short_url/main.ts b/test/api_integration/apis/short_url/delete_short_url/main.ts new file mode 100644 index 0000000000000..6f712471d8437 --- /dev/null +++ b/test/api_integration/apis/short_url/delete_short_url/main.ts @@ -0,0 +1,56 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('main', () => { + it('can delete a short URL', async () => { + const response1 = await supertest.post('/api/short_url').send({ + locatorId: 'LEGACY_SHORT_URL_LOCATOR', + params: {}, + }); + const response2 = await supertest.get('/api/short_url/' + response1.body.id); + + expect(response2.body).to.eql(response1.body); + + const response3 = await supertest.delete('/api/short_url/' + response1.body.id); + + expect(response3.status).to.eql(200); + expect(response3.body).to.eql(null); + + const response4 = await supertest.get('/api/short_url/' + response1.body.id); + + expect(response4.status).to.eql(404); + }); + + it('returns 404 when deleting already deleted short URL', async () => { + const response1 = await supertest.post('/api/short_url').send({ + locatorId: 'LEGACY_SHORT_URL_LOCATOR', + params: {}, + }); + + const response3 = await supertest.delete('/api/short_url/' + response1.body.id); + + expect(response3.status).to.eql(200); + + const response4 = await supertest.delete('/api/short_url/' + response1.body.id); + + expect(response4.status).to.eql(404); + }); + + it('returns 404 when deleting a non-existing model', async () => { + const response = await supertest.delete('/api/short_url/' + 'non-existing-id'); + + expect(response.status).to.eql(404); + }); + }); +} diff --git a/test/api_integration/apis/short_url/delete_short_url/validation.ts b/test/api_integration/apis/short_url/delete_short_url/validation.ts new file mode 100644 index 0000000000000..bea6453060153 --- /dev/null +++ b/test/api_integration/apis/short_url/delete_short_url/validation.ts @@ -0,0 +1,40 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('validation', () => { + it('errors when short URL ID is too short', async () => { + const response = await supertest.delete('/api/short_url/ab'); + + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + '[request params.id]: value has length [2] but it must have a minimum length of [4].', + }); + }); + + it('errors when short URL ID is too long', async () => { + const response = await supertest.delete( + '/api/short_url/abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij' + ); + + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + '[request params.id]: value has length [130] but it must have a maximum length of [128].', + }); + }); + }); +} diff --git a/test/api_integration/apis/short_url/get_short_url/index.ts b/test/api_integration/apis/short_url/get_short_url/index.ts new file mode 100644 index 0000000000000..396feea2e5ddb --- /dev/null +++ b/test/api_integration/apis/short_url/get_short_url/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('get_short_url', () => { + loadTestFile(require.resolve('./validation')); + loadTestFile(require.resolve('./main')); + }); +} diff --git a/test/api_integration/apis/short_url/get_short_url/main.ts b/test/api_integration/apis/short_url/get_short_url/main.ts new file mode 100644 index 0000000000000..692c907874255 --- /dev/null +++ b/test/api_integration/apis/short_url/get_short_url/main.ts @@ -0,0 +1,51 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('main', () => { + it('can fetch a newly created short URL', async () => { + const response1 = await supertest.post('/api/short_url').send({ + locatorId: 'LEGACY_SHORT_URL_LOCATOR', + params: {}, + }); + const response2 = await supertest.get('/api/short_url/' + response1.body.id); + + expect(response2.body).to.eql(response1.body); + }); + + it('supports legacy short URLs', async () => { + const id = 'abcdefghjabcdefghjabcdefghjabcdefghj'; + await supertest.post('/api/saved_objects/url/' + id).send({ + attributes: { + accessCount: 25, + accessDate: 1632672537546, + createDate: 1632672507685, + url: '/app/dashboards#/view/123', + }, + }); + const response = await supertest.get('/api/short_url/' + id); + await supertest.delete('/api/saved_objects/url/' + id).send(); + + expect(response.body.id).to.be(id); + expect(response.body.slug).to.be(id); + expect(response.body.locator).to.eql({ + id: 'LEGACY_SHORT_URL_LOCATOR', + version: '7.15.0', + state: { url: '/app/dashboards#/view/123' }, + }); + expect(response.body.accessCount).to.be(25); + expect(response.body.accessDate).to.be(1632672537546); + expect(response.body.createDate).to.be(1632672507685); + }); + }); +} diff --git a/test/api_integration/apis/short_url/get_short_url/validation.ts b/test/api_integration/apis/short_url/get_short_url/validation.ts new file mode 100644 index 0000000000000..ea3bce0844e04 --- /dev/null +++ b/test/api_integration/apis/short_url/get_short_url/validation.ts @@ -0,0 +1,40 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('validation', () => { + it('errors when short URL ID is too short', async () => { + const response = await supertest.get('/api/short_url/ab'); + + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + '[request params.id]: value has length [2] but it must have a minimum length of [4].', + }); + }); + + it('errors when short URL ID is too long', async () => { + const response = await supertest.get( + '/api/short_url/abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij' + ); + + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + '[request params.id]: value has length [130] but it must have a maximum length of [128].', + }); + }); + }); +} diff --git a/test/api_integration/apis/short_url/index.ts b/test/api_integration/apis/short_url/index.ts new file mode 100644 index 0000000000000..e8645bad6547a --- /dev/null +++ b/test/api_integration/apis/short_url/index.ts @@ -0,0 +1,18 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('short_url', () => { + loadTestFile(require.resolve('./create_short_url')); + loadTestFile(require.resolve('./get_short_url')); + loadTestFile(require.resolve('./delete_short_url')); + loadTestFile(require.resolve('./resolve_short_url')); + }); +} diff --git a/test/api_integration/apis/short_url/resolve_short_url/index.ts b/test/api_integration/apis/short_url/resolve_short_url/index.ts new file mode 100644 index 0000000000000..057bd1c0732b2 --- /dev/null +++ b/test/api_integration/apis/short_url/resolve_short_url/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('resolve_short_url', () => { + loadTestFile(require.resolve('./validation')); + loadTestFile(require.resolve('./main')); + }); +} diff --git a/test/api_integration/apis/short_url/resolve_short_url/main.ts b/test/api_integration/apis/short_url/resolve_short_url/main.ts new file mode 100644 index 0000000000000..a1cf693bd4a53 --- /dev/null +++ b/test/api_integration/apis/short_url/resolve_short_url/main.ts @@ -0,0 +1,55 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('main', () => { + it('can resolve a short URL by its slug', async () => { + const rnd = Math.round(Math.random() * 1e6) + 1; + const slug = 'test-slug-' + Date.now() + '-' + rnd; + const response1 = await supertest.post('/api/short_url').send({ + locatorId: 'LEGACY_SHORT_URL_LOCATOR', + params: {}, + slug, + }); + const response2 = await supertest.get('/api/short_url/_slug/' + slug); + + expect(response2.body).to.eql(response1.body); + }); + + it('can resolve a short URL by its slug, when slugs are similar', async () => { + const rnd = Math.round(Math.random() * 1e6) + 1; + const now = Date.now(); + const slug1 = 'test-slug-' + now + '-' + rnd + '.1'; + const slug2 = 'test-slug-' + now + '-' + rnd + '.2'; + const response1 = await supertest.post('/api/short_url').send({ + locatorId: 'LEGACY_SHORT_URL_LOCATOR', + params: { + url: '/path1', + }, + slug: slug1, + }); + const response2 = await supertest.post('/api/short_url').send({ + locatorId: 'LEGACY_SHORT_URL_LOCATOR', + params: { + url: '/path2', + }, + slug: slug2, + }); + const response3 = await supertest.get('/api/short_url/_slug/' + slug1); + const response4 = await supertest.get('/api/short_url/_slug/' + slug2); + + expect(response1.body).to.eql(response3.body); + expect(response2.body).to.eql(response4.body); + }); + }); +} diff --git a/test/api_integration/apis/short_url/resolve_short_url/validation.ts b/test/api_integration/apis/short_url/resolve_short_url/validation.ts new file mode 100644 index 0000000000000..c77e58d894735 --- /dev/null +++ b/test/api_integration/apis/short_url/resolve_short_url/validation.ts @@ -0,0 +1,40 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('validation', () => { + it('errors when short URL slug is too short', async () => { + const response = await supertest.get('/api/short_url/_slug/aa'); + + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + '[request params.slug]: value has length [2] but it must have a minimum length of [4].', + }); + }); + + it('errors when short URL ID is too long', async () => { + const response = await supertest.get( + '/api/short_url/_slug/abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij' + ); + + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + '[request params.slug]: value has length [130] but it must have a maximum length of [128].', + }); + }); + }); +} diff --git a/test/api_integration/apis/shorten/index.js b/test/api_integration/apis/shorten/index.js deleted file mode 100644 index 86c39426205ad..0000000000000 --- a/test/api_integration/apis/shorten/index.js +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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 expect from '@kbn/expect'; - -export default function ({ getService }) { - const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); - - describe('url shortener', () => { - before(async () => { - await kibanaServer.importExport.load( - 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' - ); - }); - after(async () => { - await kibanaServer.importExport.unload( - 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' - ); - }); - - it('generates shortened urls', async () => { - const resp = await supertest - .post('/api/shorten_url') - .set('content-type', 'application/json') - .send({ url: '/app/visualize#/create' }) - .expect(200); - - expect(resp.body).to.have.property('urlId'); - expect(typeof resp.body.urlId).to.be('string'); - expect(resp.body.urlId.length > 0).to.be(true); - }); - - it('redirects shortened urls', async () => { - const resp = await supertest - .post('/api/shorten_url') - .set('content-type', 'application/json') - .send({ url: '/app/visualize#/create' }); - - const urlId = resp.body.urlId; - await supertest - .get(`/goto/${urlId}`) - .expect(302) - .expect('location', '/app/visualize#/create'); - }); - }); -} diff --git a/test/functional/apps/discover/_shared_links.ts b/test/functional/apps/discover/_shared_links.ts index 62364739db311..e16dcc11eae4c 100644 --- a/test/functional/apps/discover/_shared_links.ts +++ b/test/functional/apps/discover/_shared_links.ts @@ -89,7 +89,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should allow for copying the snapshot URL as a short URL', async function () { - const re = new RegExp(baseUrl + '/goto/[0-9a-f]{32}$'); + const re = new RegExp(baseUrl + '/goto/.+$'); await PageObjects.share.checkShortenUrl(); await retry.try(async () => { const actualUrl = await PageObjects.share.getSharedUrl(); @@ -148,7 +148,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should allow for copying the snapshot URL as a short URL and should open it', async function () { - const re = new RegExp(baseUrl + '/goto/[0-9a-f]{32}$'); + const re = new RegExp(baseUrl + '/goto/.+$'); await PageObjects.share.checkShortenUrl(); let actualUrl: string = ''; await retry.try(async () => { diff --git a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx index 0a94dd14b3ca0..eb0087d180146 100644 --- a/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx +++ b/x-pack/plugins/apm/public/context/apm_plugin/mock_apm_plugin_context.tsx @@ -99,6 +99,7 @@ const urlService = new UrlService({ getUrl: async ({ app, path }, { absolute }) => { return `${absolute ? 'http://localhost:8888' : ''}/app/${app}${path}`; }, + shortUrls: {} as any, }); const locator = urlService.locators.create(new MlLocatorDefinition()); diff --git a/x-pack/test/api_integration/apis/index.ts b/x-pack/test/api_integration/apis/index.ts index 0d345db58d24f..eed39fd6dc6dc 100644 --- a/x-pack/test/api_integration/apis/index.ts +++ b/x-pack/test/api_integration/apis/index.ts @@ -27,7 +27,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./uptime')); loadTestFile(require.resolve('./maps')); loadTestFile(require.resolve('./security_solution')); - loadTestFile(require.resolve('./short_urls')); loadTestFile(require.resolve('./lens')); loadTestFile(require.resolve('./ml')); loadTestFile(require.resolve('./transform')); diff --git a/x-pack/test/api_integration/apis/short_urls/feature_controls.ts b/x-pack/test/api_integration/apis/short_urls/feature_controls.ts deleted file mode 100644 index a2596e9eaedaf..0000000000000 --- a/x-pack/test/api_integration/apis/short_urls/feature_controls.ts +++ /dev/null @@ -1,197 +0,0 @@ -/* - * 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 expect from '@kbn/expect'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function featureControlsTests({ getService }: FtrProviderContext) { - const supertest = getService('supertestWithoutAuth'); - const security = getService('security'); - - describe('feature controls', () => { - const kibanaUsername = 'kibana_admin'; - const kibanaUserRoleName = 'kibana_admin'; - - const kibanaUserPassword = `${kibanaUsername}-password`; - - let urlId: string; - - // a sampling of features to test against - const features = [ - { - featureId: 'discover', - canAccess: true, - canCreate: true, - }, - { - featureId: 'dashboard', - canAccess: true, - canCreate: true, - }, - { - featureId: 'visualize', - canAccess: true, - canCreate: true, - }, - { - featureId: 'infrastructure', - canAccess: true, - canCreate: false, - }, - { - featureId: 'canvas', - canAccess: true, - canCreate: false, - }, - { - featureId: 'maps', - canAccess: true, - canCreate: false, - }, - { - featureId: 'unknown-feature', - canAccess: false, - canCreate: false, - }, - ]; - - before(async () => { - for (const feature of features) { - await security.role.create(`${feature.featureId}-role`, { - kibana: [ - { - base: [], - feature: { - [feature.featureId]: ['read'], - }, - spaces: ['*'], - }, - ], - }); - await security.role.create(`${feature.featureId}-minimal-role`, { - kibana: [ - { - base: [], - feature: { - [feature.featureId]: ['minimal_all'], - }, - spaces: ['*'], - }, - ], - }); - await security.role.create(`${feature.featureId}-minimal-shorten-role`, { - kibana: [ - { - base: [], - feature: { - [feature.featureId]: ['minimal_read', 'url_create'], - }, - spaces: ['*'], - }, - ], - }); - - await security.user.create(`${feature.featureId}-user`, { - password: kibanaUserPassword, - roles: [`${feature.featureId}-role`], - full_name: 'a kibana user', - }); - - await security.user.create(`${feature.featureId}-minimal-user`, { - password: kibanaUserPassword, - roles: [`${feature.featureId}-minimal-role`], - full_name: 'a kibana user', - }); - - await security.user.create(`${feature.featureId}-minimal-shorten-user`, { - password: kibanaUserPassword, - roles: [`${feature.featureId}-minimal-shorten-role`], - full_name: 'a kibana user', - }); - } - - await security.user.create(kibanaUsername, { - password: kibanaUserPassword, - roles: [kibanaUserRoleName], - full_name: 'a kibana user', - }); - - await supertest - .post(`/api/shorten_url`) - .auth(kibanaUsername, kibanaUserPassword) - .set('kbn-xsrf', 'foo') - .send({ url: '/app/kibana#foo/bar/baz' }) - .then((resp: Record) => { - urlId = resp.body.urlId; - }); - }); - - after(async () => { - const users = features.flatMap((feature) => [ - security.user.delete(`${feature.featureId}-user`), - security.user.delete(`${feature.featureId}-minimal-user`), - security.user.delete(`${feature.featureId}-minimal-shorten-user`), - ]); - const roles = features.flatMap((feature) => [ - security.role.delete(`${feature.featureId}-role`), - security.role.delete(`${feature.featureId}-minimal-role`), - security.role.delete(`${feature.featureId}-minimal-shorten-role`), - ]); - await Promise.all([...users, ...roles]); - await security.user.delete(kibanaUsername); - }); - - features.forEach((feature) => { - it(`users with "read" access to ${feature.featureId} ${ - feature.canAccess ? 'should' : 'should not' - } be able to access short-urls`, async () => { - await supertest - .get(`/goto/${urlId}`) - .auth(`${feature.featureId}-user`, kibanaUserPassword) - .then((resp: Record) => { - if (feature.canAccess) { - expect(resp.status).to.eql(302); - expect(resp.headers.location).to.eql('/app/kibana#foo/bar/baz'); - } else { - expect(resp.status).to.eql(403); - expect(resp.headers.location).to.eql(undefined); - } - }); - }); - - it(`users with "minimal_all" access to ${feature.featureId} should not be able to create short-urls`, async () => { - await supertest - .post(`/api/shorten_url`) - .auth(`${feature.featureId}-minimal-user`, kibanaUserPassword) - .set('kbn-xsrf', 'foo') - .send({ url: '/app/dashboard' }) - .then((resp: Record) => { - expect(resp.status).to.eql(403); - expect(resp.body.message).to.eql('Unable to create url'); - }); - }); - - it(`users with "url_create" access to ${feature.featureId} ${ - feature.canCreate ? 'should' : 'should not' - } be able to create short-urls`, async () => { - await supertest - .post(`/api/shorten_url`) - .auth(`${feature.featureId}-minimal-shorten-user`, kibanaUserPassword) - .set('kbn-xsrf', 'foo') - .send({ url: '/app/dashboard' }) - .then((resp: Record) => { - if (feature.canCreate) { - expect(resp.status).to.eql(200); - } else { - expect(resp.status).to.eql(403); - expect(resp.body.message).to.eql('Unable to create url'); - } - }); - }); - }); - }); -} diff --git a/x-pack/test/api_integration/apis/short_urls/index.ts b/x-pack/test/api_integration/apis/short_urls/index.ts deleted file mode 100644 index 2332fdea8043a..0000000000000 --- a/x-pack/test/api_integration/apis/short_urls/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * 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 { FtrProviderContext } from '../../ftr_provider_context'; - -export default function shortUrlsApiIntegrationTests({ loadTestFile }: FtrProviderContext) { - describe('Short URLs', () => { - loadTestFile(require.resolve('./feature_controls')); - }); -} diff --git a/yarn.lock b/yarn.lock index 30227f59a74aa..23deebc50f212 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22426,6 +22426,11 @@ random-poly-fill@^1.0.1: resolved "https://registry.yarnpkg.com/random-poly-fill/-/random-poly-fill-1.0.1.tgz#13634dc0255a31ecf85d4a182d92c40f9bbcf5ed" integrity sha512-bMOL0hLfrNs52+EHtIPIXxn2PxYwXb0qjnKruTjXiM/sKfYqj506aB2plFwWW1HN+ri724bAVVGparh4AtlJKw== +random-word-slugs@^0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/random-word-slugs/-/random-word-slugs-0.0.5.tgz#6ccd6c7ea320be9fbc19507f8c3a7d4a970ff61f" + integrity sha512-KwelmWsWHiMl3MKauB5usAIPg2FgwAku+FVYEuf32yyhZmEh3Fq4nXBxeUAgXB2F+G/HTeDsqXsmuupmOMnjRg== + randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"