Co-authored-by: Vadim Kibana <82822460+vadimkibana@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2021-09-28 07:04:15 -04:00 committed by GitHub
parent bd669de514
commit 8e6b834540
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
68 changed files with 2177 additions and 788 deletions

View file

@ -319,6 +319,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",

View file

@ -37,6 +37,22 @@ export const urlServiceTestSetup = (partialDeps: Partial<UrlServiceDependencies>
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);

View file

@ -8,3 +8,4 @@
export * from './url_service';
export * from './locators';
export * from './short_urls';

View file

@ -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<LegacyShortUrlLocatorParams>
{
public readonly id = LEGACY_SHORT_URL_LOCATOR_ID;
public async getLocation(params: LegacyShortUrlLocatorParams): Promise<KibanaLocation> {
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: {},
};
}
}

View file

@ -43,12 +43,14 @@ export interface LocatorDependencies {
}
export class Locator<P extends SerializableRecord> implements LocatorPublic<P> {
public readonly id: string;
public readonly migrations: PersistableState<P>['migrations'];
constructor(
public readonly definition: LocatorDefinition<P>,
protected readonly deps: LocatorDependencies
) {
this.id = definition.id;
this.migrations = definition.migrations || {};
}

View file

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

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
const REGEX = /^\/app\/[^/]+.+$/;
export function shortUrlAssertValid(url: string) {
if (!REGEX.test(url) || url.includes('/../')) throw new Error(`Invalid short URL: ${url}`);
}

View file

@ -53,6 +53,8 @@ export interface LocatorDefinition<P extends SerializableRecord>
* Public interface of a registered locator.
*/
export interface LocatorPublic<P extends SerializableRecord> extends PersistableState<P> {
readonly id: string;
/**
* Returns a reference to a Kibana client-side location.
*

View file

@ -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.');
},
}),
},
});
}
}

View file

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

View file

@ -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<D> {
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<P extends SerializableRecord>(params: ShortUrlCreateParams<P>): Promise<ShortUrl<P>>;
/**
* Delete a short URL.
*
* @param slug The ID of the short URL.
*/
delete(id: string): Promise<void>;
/**
* Fetch a short URL.
*
* @param id The ID of the short URL.
*/
get(id: string): Promise<ShortUrl>;
/**
* Fetch a short URL by its slug.
*
* @param slug The slug of the short URL.
*/
resolve(slug: string): Promise<ShortUrl>;
}
/**
* New short URL creation parameters.
*/
export interface ShortUrlCreateParams<P extends SerializableRecord> {
/**
* Locator which will be used to resolve the short URL.
*/
locator: LocatorPublic<P>;
/**
* 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: "<adjective>-<adjective>-<noun>".
*/
humanReadableSlug?: boolean;
}
/**
* A representation of a short URL.
*/
export interface ShortUrl<LocatorParams extends SerializableRecord = SerializableRecord> {
/**
* Serializable state of the short URL, which is stored in Kibana.
*/
readonly data: ShortUrlData<LocatorParams>;
}
/**
* A representation of a short URL's data.
*/
export interface ShortUrlData<LocatorParams extends SerializableRecord = SerializableRecord> {
/**
* 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<LocatorParams>;
}
/**
* Represents a serializable state of a locator. Includes locator ID, version
* and its params.
*/
export interface LocatorData<LocatorParams extends SerializableRecord = SerializableRecord>
extends VersionedState<LocatorParams> {
/**
* Locator ID.
*/
id: string;
}

View file

@ -7,19 +7,25 @@
*/
import { LocatorClient, LocatorClientDependencies } from './locators';
import { IShortUrlClientFactory } from './short_urls';
export type UrlServiceDependencies = LocatorClientDependencies;
export interface UrlServiceDependencies<D = unknown> extends LocatorClientDependencies {
shortUrls: IShortUrlClientFactory<D>;
}
/**
* Common URL Service client interface for server-side and client-side.
*/
export class UrlService {
export class UrlService<D = unknown> {
/**
* Client to work with locators.
*/
public readonly locators: LocatorClient;
constructor(protected readonly deps: UrlServiceDependencies) {
public readonly shortUrls: IShortUrlClientFactory<D>;
constructor(protected readonly deps: UrlServiceDependencies<D>) {
this.locators = new LocatorClient(deps);
this.shortUrls = deps.shortUrls;
}
}

View file

@ -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))"}',
});
});
});
});

View file

@ -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)}`,
});
}

View file

@ -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 = <T extends SerializableRecord = SerializableRecord>(): jest.Mocked<
LocatorPublic<T>
> => ({
id: 'MOCK_LOCATOR',
getLocation: jest.fn(),
getUrl: jest.fn(),
getRedirectUrl: jest.fn(),

View file

@ -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<SharePluginSetup, SharePluginStart> {
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<SharePluginSetup, SharePluginStart> {
});
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,
});

View file

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

View file

@ -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<ShortUrlData>(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 () => {};
}
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);
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 url = core.http.basePath.prepend(redirectUrl);
location.href = url;

View file

@ -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<SharePluginSetup, SharePluginStart> {
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<SharePluginSetup, SharePluginStart> {
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]: {

View file

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

View file

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

View file

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

View file

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

View file

@ -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}"`
);
}
}

View file

@ -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<SavedObjectsClientContract>;
let deps: { savedObjects: SavedObjectsClientContract };
let shortUrl: ShortUrlLookupService;
beforeEach(() => {
savedObjects = savedObjectsClientMock.create();
savedObjects.create.mockResolvedValue({ id: ID } as SavedObject<UrlAttributes>);
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);
});
});
});

View file

@ -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<string>;
getUrl(url: string, deps: { savedObjects: SavedObjectsClientContract }): Promise<string>;
}
export interface UrlAttributes {
url: string;
accessCount: number;
createDate: number;
accessDate: number;
}
export function shortUrlLookupProvider({ logger }: { logger: Logger }): ShortUrlLookupService {
async function updateMetadata(
doc: SavedObject<UrlAttributes>,
{ savedObjects }: { savedObjects: SavedObjectsClientContract }
) {
try {
await savedObjects.update<UrlAttributes>('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<UrlAttributes>(
'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<UrlAttributes>('url', id);
updateMetadata(doc, { savedObjects });
return doc.attributes.url;
},
};
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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',
});
})
);
};

View file

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

View file

@ -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/<short_url_id> route. It loads the
* /app/goto/<short_url_id> 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();
})
);
};

View file

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

View file

@ -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.',
});
})
);
};

View file

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

View file

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

View file

@ -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}"`)
);
});
});
});

View file

@ -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<P extends SerializableRecord>({
locator,
params,
slug = '',
humanReadableSlug = false,
}: ShortUrlCreateParams<P>): Promise<ShortUrl<P>> {
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<ShortUrl> {
const { storage } = this.dependencies;
const data = await storage.getById(id);
return {
data,
};
}
public async delete(id: string): Promise<void> {
const { storage } = this.dependencies;
await storage.delete(id);
}
public async resolve(slug: string): Promise<ShortUrl> {
const { storage } = this.dependencies;
const data = await storage.getBySlug(slug);
return {
data,
};
}
}

View file

@ -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<ServerShortUrlClientFactoryCreateParams>
{
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;
}
}

View file

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

View file

@ -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<string, ShortUrlData>();
public async create<P extends SerializableRecord = SerializableRecord>(
data: Omit<ShortUrlData<P>, 'id'>
): Promise<ShortUrlData<P>> {
const id = uuidv4();
const url: ShortUrlData<P> = { ...data, id };
this.urls.set(id, url);
return url;
}
public async getById<P extends SerializableRecord = SerializableRecord>(
id: string
): Promise<ShortUrlData<P>> {
if (!this.urls.has(id)) {
throw new Error(`No short url with id "${id}"`);
}
return this.urls.get(id)! as ShortUrlData<P>;
}
public async getBySlug<P extends SerializableRecord = SerializableRecord>(
slug: string
): Promise<ShortUrlData<P>> {
for (const url of this.urls.values()) {
if (url.slug === slug) {
return url as ShortUrlData<P>;
}
}
throw new Error(`No short url with slug "${slug}".`);
}
public async exists(slug: string): Promise<boolean> {
for (const url of this.urls.values()) {
if (url.slug === slug) {
return true;
}
}
return false;
}
public async delete(id: string): Promise<void> {
this.urls.delete(id);
}
}

View file

@ -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<ShortUrlSavedObjectAttributes>;
/**
* 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 = <P extends SerializableRecord = SerializableRecord>(
savedObject: ShortUrlSavedObject
): ShortUrlData<P> => {
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<P>;
}
const { locatorJSON, ...rest } = attributes;
const locator = JSON.parse(locatorJSON) as ShortUrlData<P>['locator'];
return {
id: savedObject.id,
locator,
...rest,
} as ShortUrlData<P>;
};
const createAttributes = <P extends SerializableRecord = SerializableRecord>(
data: Omit<ShortUrlData<P>, '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<P extends SerializableRecord = SerializableRecord>(
data: Omit<ShortUrlData<P>, 'id'>
): Promise<ShortUrlData<P>> {
const { savedObjects, savedObjectType } = this.dependencies;
const attributes = createAttributes(data);
const savedObject = await savedObjects.create(savedObjectType, attributes, {
refresh: true,
});
return createShortUrlData<P>(savedObject);
}
public async getById<P extends SerializableRecord = SerializableRecord>(
id: string
): Promise<ShortUrlData<P>> {
const { savedObjects, savedObjectType } = this.dependencies;
const savedObject = await savedObjects.get<ShortUrlSavedObjectAttributes>(savedObjectType, id);
return createShortUrlData<P>(savedObject);
}
public async getBySlug<P extends SerializableRecord = SerializableRecord>(
slug: string
): Promise<ShortUrlData<P>> {
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<P>(savedObject);
}
public async exists(slug: string): Promise<boolean> {
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<void> {
const { savedObjects, savedObjectType } = this.dependencies;
await savedObjects.delete(savedObjectType, id);
}
}

View file

@ -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<P extends SerializableRecord = SerializableRecord>(
data: Omit<ShortUrlData<P>, 'id'>
): Promise<ShortUrlData<P>>;
/**
* Fetch a short URL entry by ID.
*/
getById<P extends SerializableRecord = SerializableRecord>(id: string): Promise<ShortUrlData<P>>;
/**
* Fetch a short URL entry by slug.
*/
getBySlug<P extends SerializableRecord = SerializableRecord>(
slug: string
): Promise<ShortUrlData<P>>;
/**
* Checks if a short URL exists by slug.
*/
exists(slug: string): Promise<boolean>;
/**
* Delete an existing short URL entry.
*/
delete(id: string): Promise<void>;
}

View file

@ -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]."`
);
});
});

View file

@ -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}].`);
}
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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].',
});
});
});
}

View file

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

View file

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

View file

@ -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].',
});
});
});
}

View file

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

View file

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

View file

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

View file

@ -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].',
});
});
});
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, any>) => {
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<string, any>) => {
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<string, any>) => {
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<string, any>) => {
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');
}
});
});
});
});
}

View file

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

View file

@ -22446,6 +22446,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"