mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
Add UiSettings validation & Kibana default route redirection (#59694)
* add schema to ui settings params * add validation for defaults and overrides * validate in ui settings client * ui settings routes validation * clean up tests * use schema for defaultRoutes * move URL redirection to NP * fix spaces test * update docs * update kbn pm * fix karma test * fix tests * address comments * get rid of getDEfaultRoute * regen docs * fix tests * fix enter-spaces test * validate on relative url format * update i18n * fix enter-spoace test * move relative url validation to utils * add CoreApp containing application logic * extract public uiSettings params in a separate type * make schema required * update docs
This commit is contained in:
parent
271c9597be
commit
dd7531deb4
63 changed files with 730 additions and 378 deletions
|
@ -9,5 +9,5 @@ Gets the metadata about all uiSettings, including the type, default value, and u
|
|||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
getAll: () => Readonly<Record<string, UiSettingsParams & UserProvidedValues>>;
|
||||
getAll: () => Readonly<Record<string, PublicUiSettingsParams & UserProvidedValues>>;
|
||||
```
|
||||
|
|
|
@ -18,7 +18,7 @@ export interface IUiSettingsClient
|
|||
| --- | --- | --- |
|
||||
| [get](./kibana-plugin-core-public.iuisettingsclient.get.md) | <code><T = any>(key: string, defaultOverride?: T) => T</code> | Gets the value for a specific uiSetting. If this setting has no user-defined value then the <code>defaultOverride</code> parameter is returned (and parsed if setting is of type "json" or "number). If the parameter is not defined and the key is not registered by any plugin then an error is thrown, otherwise reads the default value defined by a plugin. |
|
||||
| [get$](./kibana-plugin-core-public.iuisettingsclient.get_.md) | <code><T = any>(key: string, defaultOverride?: T) => Observable<T></code> | Gets an observable of the current value for a config key, and all updates to that config key in the future. Providing a <code>defaultOverride</code> argument behaves the same as it does in \#get() |
|
||||
| [getAll](./kibana-plugin-core-public.iuisettingsclient.getall.md) | <code>() => Readonly<Record<string, UiSettingsParams & UserProvidedValues>></code> | Gets the metadata about all uiSettings, including the type, default value, and user value for each key. |
|
||||
| [getAll](./kibana-plugin-core-public.iuisettingsclient.getall.md) | <code>() => Readonly<Record<string, PublicUiSettingsParams & UserProvidedValues>></code> | Gets the metadata about all uiSettings, including the type, default value, and user value for each key. |
|
||||
| [getSaved$](./kibana-plugin-core-public.iuisettingsclient.getsaved_.md) | <code><T = any>() => Observable<{</code><br/><code> key: string;</code><br/><code> newValue: T;</code><br/><code> oldValue: T;</code><br/><code> }></code> | Returns an Observable that notifies subscribers of each update to the uiSettings, including the key, newValue, and oldValue of the setting that changed. |
|
||||
| [getUpdate$](./kibana-plugin-core-public.iuisettingsclient.getupdate_.md) | <code><T = any>() => Observable<{</code><br/><code> key: string;</code><br/><code> newValue: T;</code><br/><code> oldValue: T;</code><br/><code> }></code> | Returns an Observable that notifies subscribers of each update to the uiSettings, including the key, newValue, and oldValue of the setting that changed. |
|
||||
| [getUpdateErrors$](./kibana-plugin-core-public.iuisettingsclient.getupdateerrors_.md) | <code>() => Observable<Error></code> | Returns an Observable that notifies subscribers of each error while trying to update the settings, containing the actual Error class. |
|
||||
|
|
|
@ -147,6 +147,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
|
|||
| [MountPoint](./kibana-plugin-core-public.mountpoint.md) | A function that should mount DOM content inside the provided container element and return a handler to unmount it. |
|
||||
| [PluginInitializer](./kibana-plugin-core-public.plugininitializer.md) | The <code>plugin</code> export at the root of a plugin's <code>public</code> directory should conform to this interface. |
|
||||
| [PluginOpaqueId](./kibana-plugin-core-public.pluginopaqueid.md) | |
|
||||
| [PublicUiSettingsParams](./kibana-plugin-core-public.publicuisettingsparams.md) | A sub-set of [UiSettingsParams](./kibana-plugin-core-public.uisettingsparams.md) exposed to the client-side. |
|
||||
| [RecursiveReadonly](./kibana-plugin-core-public.recursivereadonly.md) | |
|
||||
| [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) | Type definition for a Saved Object attribute value |
|
||||
| [SavedObjectAttributeSingle](./kibana-plugin-core-public.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-core-public.savedobjectattribute.md) |
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [PublicUiSettingsParams](./kibana-plugin-core-public.publicuisettingsparams.md)
|
||||
|
||||
## PublicUiSettingsParams type
|
||||
|
||||
A sub-set of [UiSettingsParams](./kibana-plugin-core-public.uisettingsparams.md) exposed to the client-side.
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
export declare type PublicUiSettingsParams = Omit<UiSettingsParams, 'schema'>;
|
||||
```
|
|
@ -9,7 +9,7 @@ UiSettings parameters defined by the plugins.
|
|||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
export interface UiSettingsParams
|
||||
export interface UiSettingsParams<T = unknown>
|
||||
```
|
||||
|
||||
## Properties
|
||||
|
@ -24,7 +24,8 @@ export interface UiSettingsParams
|
|||
| [options](./kibana-plugin-core-public.uisettingsparams.options.md) | <code>string[]</code> | array of permitted values for this setting |
|
||||
| [readonly](./kibana-plugin-core-public.uisettingsparams.readonly.md) | <code>boolean</code> | a flag indicating that value cannot be changed |
|
||||
| [requiresPageReload](./kibana-plugin-core-public.uisettingsparams.requirespagereload.md) | <code>boolean</code> | a flag indicating whether new value applying requires page reloading |
|
||||
| [schema](./kibana-plugin-core-public.uisettingsparams.schema.md) | <code>Type<T></code> | |
|
||||
| [type](./kibana-plugin-core-public.uisettingsparams.type.md) | <code>UiSettingsType</code> | defines a type of UI element [UiSettingsType](./kibana-plugin-core-public.uisettingstype.md) |
|
||||
| [validation](./kibana-plugin-core-public.uisettingsparams.validation.md) | <code>ImageValidation | StringValidation</code> | |
|
||||
| [value](./kibana-plugin-core-public.uisettingsparams.value.md) | <code>SavedObjectAttribute</code> | default value to fall back to if a user doesn't provide any |
|
||||
| [value](./kibana-plugin-core-public.uisettingsparams.value.md) | <code>T</code> | default value to fall back to if a user doesn't provide any |
|
||||
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [UiSettingsParams](./kibana-plugin-core-public.uisettingsparams.md) > [schema](./kibana-plugin-core-public.uisettingsparams.schema.md)
|
||||
|
||||
## UiSettingsParams.schema property
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
schema: Type<T>;
|
||||
```
|
|
@ -9,5 +9,5 @@ default value to fall back to if a user doesn't provide any
|
|||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
value?: SavedObjectAttribute;
|
||||
value?: T;
|
||||
```
|
||||
|
|
|
@ -9,5 +9,5 @@ Returns registered uiSettings values [UiSettingsParams](./kibana-plugin-core-ser
|
|||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
getRegistered: () => Readonly<Record<string, UiSettingsParams>>;
|
||||
getRegistered: () => Readonly<Record<string, PublicUiSettingsParams>>;
|
||||
```
|
||||
|
|
|
@ -18,7 +18,7 @@ export interface IUiSettingsClient
|
|||
| --- | --- | --- |
|
||||
| [get](./kibana-plugin-core-server.iuisettingsclient.get.md) | <code><T = any>(key: string) => Promise<T></code> | Retrieves uiSettings values set by the user with fallbacks to default values if not specified. |
|
||||
| [getAll](./kibana-plugin-core-server.iuisettingsclient.getall.md) | <code><T = any>() => Promise<Record<string, T>></code> | Retrieves a set of all uiSettings values set by the user with fallbacks to default values if not specified. |
|
||||
| [getRegistered](./kibana-plugin-core-server.iuisettingsclient.getregistered.md) | <code>() => Readonly<Record<string, UiSettingsParams>></code> | Returns registered uiSettings values [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) |
|
||||
| [getRegistered](./kibana-plugin-core-server.iuisettingsclient.getregistered.md) | <code>() => Readonly<Record<string, PublicUiSettingsParams>></code> | Returns registered uiSettings values [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) |
|
||||
| [getUserProvided](./kibana-plugin-core-server.iuisettingsclient.getuserprovided.md) | <code><T = any>() => Promise<Record<string, UserProvidedValues<T>>></code> | Retrieves a set of all uiSettings values set by the user. |
|
||||
| [isOverridden](./kibana-plugin-core-server.iuisettingsclient.isoverridden.md) | <code>(key: string) => boolean</code> | Shows whether the uiSettings value set by the user. |
|
||||
| [remove](./kibana-plugin-core-server.iuisettingsclient.remove.md) | <code>(key: string) => Promise<void></code> | Removes uiSettings value by key. |
|
||||
|
|
|
@ -232,6 +232,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
|
|||
| [PluginInitializer](./kibana-plugin-core-server.plugininitializer.md) | The <code>plugin</code> export at the root of a plugin's <code>server</code> directory should conform to this interface. |
|
||||
| [PluginName](./kibana-plugin-core-server.pluginname.md) | Dedicated type for plugin name/id that is supposed to make Map/Set/Arrays that use it as a key or value more obvious. |
|
||||
| [PluginOpaqueId](./kibana-plugin-core-server.pluginopaqueid.md) | |
|
||||
| [PublicUiSettingsParams](./kibana-plugin-core-server.publicuisettingsparams.md) | A sub-set of [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) exposed to the client-side. |
|
||||
| [RecursiveReadonly](./kibana-plugin-core-server.recursivereadonly.md) | |
|
||||
| [RedirectResponseOptions](./kibana-plugin-core-server.redirectresponseoptions.md) | HTTP response parameters for redirection response |
|
||||
| [RequestHandler](./kibana-plugin-core-server.requesthandler.md) | A function executed when route path matched requested resource path. Request handler is expected to return a result of one of [KibanaResponseFactory](./kibana-plugin-core-server.kibanaresponsefactory.md) functions. |
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [PublicUiSettingsParams](./kibana-plugin-core-server.publicuisettingsparams.md)
|
||||
|
||||
## PublicUiSettingsParams type
|
||||
|
||||
A sub-set of [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) exposed to the client-side.
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
export declare type PublicUiSettingsParams = Omit<UiSettingsParams, 'schema'>;
|
||||
```
|
|
@ -9,7 +9,7 @@ UiSettings parameters defined by the plugins.
|
|||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
export interface UiSettingsParams
|
||||
export interface UiSettingsParams<T = unknown>
|
||||
```
|
||||
|
||||
## Properties
|
||||
|
@ -24,7 +24,8 @@ export interface UiSettingsParams
|
|||
| [options](./kibana-plugin-core-server.uisettingsparams.options.md) | <code>string[]</code> | array of permitted values for this setting |
|
||||
| [readonly](./kibana-plugin-core-server.uisettingsparams.readonly.md) | <code>boolean</code> | a flag indicating that value cannot be changed |
|
||||
| [requiresPageReload](./kibana-plugin-core-server.uisettingsparams.requirespagereload.md) | <code>boolean</code> | a flag indicating whether new value applying requires page reloading |
|
||||
| [schema](./kibana-plugin-core-server.uisettingsparams.schema.md) | <code>Type<T></code> | |
|
||||
| [type](./kibana-plugin-core-server.uisettingsparams.type.md) | <code>UiSettingsType</code> | defines a type of UI element [UiSettingsType](./kibana-plugin-core-server.uisettingstype.md) |
|
||||
| [validation](./kibana-plugin-core-server.uisettingsparams.validation.md) | <code>ImageValidation | StringValidation</code> | |
|
||||
| [value](./kibana-plugin-core-server.uisettingsparams.value.md) | <code>SavedObjectAttribute</code> | default value to fall back to if a user doesn't provide any |
|
||||
| [value](./kibana-plugin-core-server.uisettingsparams.value.md) | <code>T</code> | default value to fall back to if a user doesn't provide any |
|
||||
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) > [schema](./kibana-plugin-core-server.uisettingsparams.schema.md)
|
||||
|
||||
## UiSettingsParams.schema property
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
schema: Type<T>;
|
||||
```
|
|
@ -9,5 +9,5 @@ default value to fall back to if a user doesn't provide any
|
|||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
value?: SavedObjectAttribute;
|
||||
value?: T;
|
||||
```
|
||||
|
|
|
@ -44,5 +44,8 @@ export class ValidationError extends SchemaError {
|
|||
|
||||
constructor(error: SchemaTypeError, namespace?: string) {
|
||||
super(ValidationError.extractMessage(error, namespace), error);
|
||||
|
||||
// https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work
|
||||
Object.setPrototypeOf(this, ValidationError.prototype);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -67,7 +67,7 @@ export class KbnClientUiSettings {
|
|||
* Replace all uiSettings with the `doc` values, `doc` is merged
|
||||
* with some defaults
|
||||
*/
|
||||
async replace(doc: UiSettingValues) {
|
||||
async replace(doc: UiSettingValues, { retries = 5 }: { retries?: number } = {}) {
|
||||
this.log.debug('replacing kibana config doc: %j', doc);
|
||||
|
||||
const changes: Record<string, any> = {
|
||||
|
@ -85,7 +85,7 @@ export class KbnClientUiSettings {
|
|||
method: 'POST',
|
||||
path: '/api/kibana/settings',
|
||||
body: { changes },
|
||||
retries: 5,
|
||||
retries,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
4
packages/kbn-pm/dist/index.js
vendored
4
packages/kbn-pm/dist/index.js
vendored
|
@ -43920,7 +43920,7 @@ class KbnClientUiSettings {
|
|||
* Replace all uiSettings with the `doc` values, `doc` is merged
|
||||
* with some defaults
|
||||
*/
|
||||
async replace(doc) {
|
||||
async replace(doc, { retries = 5 } = {}) {
|
||||
this.log.debug('replacing kibana config doc: %j', doc);
|
||||
const changes = {
|
||||
...this.defaults,
|
||||
|
@ -43935,7 +43935,7 @@ class KbnClientUiSettings {
|
|||
method: 'POST',
|
||||
path: '/api/kibana/settings',
|
||||
body: { changes },
|
||||
retries: 5,
|
||||
retries,
|
||||
});
|
||||
}
|
||||
/**
|
||||
|
|
|
@ -171,7 +171,7 @@ export {
|
|||
ErrorToastOptions,
|
||||
} from './notifications';
|
||||
|
||||
export { MountPoint, UnmountCallback } from './types';
|
||||
export { MountPoint, UnmountCallback, PublicUiSettingsParams } from './types';
|
||||
|
||||
/**
|
||||
* Core services exposed to the `Plugin` setup lifecycle
|
||||
|
|
|
@ -16,10 +16,11 @@ import { Location } from 'history';
|
|||
import { LocationDescriptorObject } from 'history';
|
||||
import { MaybePromise } from '@kbn/utility-types';
|
||||
import { Observable } from 'rxjs';
|
||||
import { PublicUiSettingsParams as PublicUiSettingsParams_2 } from 'src/core/server/types';
|
||||
import React from 'react';
|
||||
import * as Rx from 'rxjs';
|
||||
import { ShallowPromise } from '@kbn/utility-types';
|
||||
import { UiSettingsParams as UiSettingsParams_2 } from 'src/core/server/types';
|
||||
import { Type } from '@kbn/config-schema';
|
||||
import { UnregisterCallback } from 'history';
|
||||
import { UserProvidedValues as UserProvidedValues_2 } from 'src/core/server/types';
|
||||
|
||||
|
@ -784,7 +785,7 @@ export type IToasts = Pick<ToastsApi, 'get$' | 'add' | 'remove' | 'addSuccess' |
|
|||
export interface IUiSettingsClient {
|
||||
get$: <T = any>(key: string, defaultOverride?: T) => Observable<T>;
|
||||
get: <T = any>(key: string, defaultOverride?: T) => T;
|
||||
getAll: () => Readonly<Record<string, UiSettingsParams_2 & UserProvidedValues_2>>;
|
||||
getAll: () => Readonly<Record<string, PublicUiSettingsParams_2 & UserProvidedValues_2>>;
|
||||
getSaved$: <T = any>() => Observable<{
|
||||
key: string;
|
||||
newValue: T;
|
||||
|
@ -933,6 +934,9 @@ export interface PluginInitializerContext<ConfigSchema extends object = object>
|
|||
// @public (undocumented)
|
||||
export type PluginOpaqueId = symbol;
|
||||
|
||||
// @public
|
||||
export type PublicUiSettingsParams = Omit<UiSettingsParams, 'schema'>;
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "RecursiveReadonlyArray" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
// @public (undocumented)
|
||||
|
@ -1291,7 +1295,7 @@ export type ToastsSetup = IToasts;
|
|||
export type ToastsStart = IToasts;
|
||||
|
||||
// @public
|
||||
export interface UiSettingsParams {
|
||||
export interface UiSettingsParams<T = unknown> {
|
||||
category?: string[];
|
||||
// Warning: (ae-forgotten-export) The symbol "DeprecationSettings" needs to be exported by the entry point index.d.ts
|
||||
deprecation?: DeprecationSettings;
|
||||
|
@ -1301,16 +1305,18 @@ export interface UiSettingsParams {
|
|||
options?: string[];
|
||||
readonly?: boolean;
|
||||
requiresPageReload?: boolean;
|
||||
// (undocumented)
|
||||
schema: Type<T>;
|
||||
type?: UiSettingsType;
|
||||
// (undocumented)
|
||||
validation?: ImageValidation | StringValidation;
|
||||
value?: SavedObjectAttribute;
|
||||
value?: T;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface UiSettingsState {
|
||||
// (undocumented)
|
||||
[key: string]: UiSettingsParams_2 & UserProvidedValues_2;
|
||||
[key: string]: PublicUiSettingsParams_2 & UserProvidedValues_2;
|
||||
}
|
||||
|
||||
// @public
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
|
||||
export {
|
||||
UiSettingsParams,
|
||||
PublicUiSettingsParams,
|
||||
UserProvidedValues,
|
||||
UiSettingsType,
|
||||
ImageValidation,
|
||||
|
|
|
@ -84,21 +84,21 @@ Array [
|
|||
exports[`#batchSet rejects all promises for batched requests that fail: promise rejections 1`] = `
|
||||
Array [
|
||||
Object {
|
||||
"error": [Error: Request failed with status code: 400],
|
||||
"error": [Error: invalid],
|
||||
"isRejected": true,
|
||||
},
|
||||
Object {
|
||||
"error": [Error: Request failed with status code: 400],
|
||||
"error": [Error: invalid],
|
||||
"isRejected": true,
|
||||
},
|
||||
Object {
|
||||
"error": [Error: Request failed with status code: 400],
|
||||
"error": [Error: invalid],
|
||||
"isRejected": true,
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`#batchSet rejects on 301 1`] = `"Request failed with status code: 301"`;
|
||||
exports[`#batchSet rejects on 301 1`] = `"Moved Permanently"`;
|
||||
|
||||
exports[`#batchSet rejects on 404 response 1`] = `"Request failed with status code: 404"`;
|
||||
|
||||
|
|
|
@ -18,11 +18,11 @@
|
|||
*/
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { UiSettingsParams, UserProvidedValues } from 'src/core/server/types';
|
||||
import { PublicUiSettingsParams, UserProvidedValues } from 'src/core/server/types';
|
||||
|
||||
/** @public */
|
||||
export interface UiSettingsState {
|
||||
[key: string]: UiSettingsParams & UserProvidedValues;
|
||||
[key: string]: PublicUiSettingsParams & UserProvidedValues;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -53,7 +53,7 @@ export interface IUiSettingsClient {
|
|||
* Gets the metadata about all uiSettings, including the type, default value, and user value
|
||||
* for each key.
|
||||
*/
|
||||
getAll: () => Readonly<Record<string, UiSettingsParams & UserProvidedValues>>;
|
||||
getAll: () => Readonly<Record<string, PublicUiSettingsParams & UserProvidedValues>>;
|
||||
|
||||
/**
|
||||
* Sets the value for a uiSetting. If the setting is not registered by any plugin
|
||||
|
|
|
@ -148,7 +148,7 @@ describe('#batchSet', () => {
|
|||
'*',
|
||||
{
|
||||
status: 400,
|
||||
body: 'invalid',
|
||||
body: { message: 'invalid' },
|
||||
},
|
||||
{
|
||||
overwriteRoutes: false,
|
||||
|
|
|
@ -152,10 +152,14 @@ export class UiSettingsApi {
|
|||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.response && err.response.status >= 300) {
|
||||
throw new Error(`Request failed with status code: ${err.response.status}`);
|
||||
if (err.response) {
|
||||
if (err.response.status === 400) {
|
||||
throw new Error(err.body.message);
|
||||
}
|
||||
if (err.response.status > 400) {
|
||||
throw new Error(`Request failed with status code: ${err.response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw err;
|
||||
} finally {
|
||||
this.loadingCount$.next(this.loadingCount$.getValue() - 1);
|
||||
|
|
|
@ -21,14 +21,14 @@ import { cloneDeep, defaultsDeep } from 'lodash';
|
|||
import { Observable, Subject, concat, defer, of } from 'rxjs';
|
||||
import { filter, map } from 'rxjs/operators';
|
||||
|
||||
import { UiSettingsParams, UserProvidedValues } from 'src/core/server/types';
|
||||
import { UserProvidedValues, PublicUiSettingsParams } from 'src/core/server/types';
|
||||
import { IUiSettingsClient, UiSettingsState } from './types';
|
||||
|
||||
import { UiSettingsApi } from './ui_settings_api';
|
||||
|
||||
interface UiSettingsClientParams {
|
||||
api: UiSettingsApi;
|
||||
defaults: Record<string, UiSettingsParams>;
|
||||
defaults: Record<string, PublicUiSettingsParams>;
|
||||
initialSettings?: UiSettingsState;
|
||||
done$: Observable<unknown>;
|
||||
}
|
||||
|
@ -39,8 +39,8 @@ export class UiSettingsClient implements IUiSettingsClient {
|
|||
private readonly updateErrors$ = new Subject<Error>();
|
||||
|
||||
private readonly api: UiSettingsApi;
|
||||
private readonly defaults: Record<string, UiSettingsParams>;
|
||||
private cache: Record<string, UiSettingsParams & UserProvidedValues>;
|
||||
private readonly defaults: Record<string, PublicUiSettingsParams>;
|
||||
private cache: Record<string, PublicUiSettingsParams & UserProvidedValues>;
|
||||
|
||||
constructor(params: UiSettingsClientParams) {
|
||||
this.api = params.api;
|
||||
|
|
52
src/core/server/core_app/core_app.ts
Normal file
52
src/core/server/core_app/core_app.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { InternalCoreSetup } from '../internal_types';
|
||||
import { CoreContext } from '../core_context';
|
||||
import { Logger } from '../logging';
|
||||
|
||||
/** @internal */
|
||||
export class CoreApp {
|
||||
private readonly logger: Logger;
|
||||
constructor(core: CoreContext) {
|
||||
this.logger = core.logger.get('core-app');
|
||||
}
|
||||
setup(coreSetup: InternalCoreSetup) {
|
||||
this.logger.debug('Setting up core app.');
|
||||
this.registerDefaultRoutes(coreSetup);
|
||||
}
|
||||
|
||||
private registerDefaultRoutes(coreSetup: InternalCoreSetup) {
|
||||
const httpSetup = coreSetup.http;
|
||||
const router = httpSetup.createRouter('/');
|
||||
router.get({ path: '/', validate: false }, async (context, req, res) => {
|
||||
const defaultRoute = await context.core.uiSettings.client.get<string>('defaultRoute');
|
||||
const basePath = httpSetup.basePath.get(req);
|
||||
const url = `${basePath}${defaultRoute}`;
|
||||
|
||||
return res.redirected({
|
||||
headers: {
|
||||
location: url,
|
||||
},
|
||||
});
|
||||
});
|
||||
router.get({ path: '/core', validate: false }, async (context, req, res) =>
|
||||
res.ok({ body: { version: '0.0.1' } })
|
||||
);
|
||||
}
|
||||
}
|
20
src/core/server/core_app/index.ts
Normal file
20
src/core/server/core_app/index.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export { CoreApp } from './core_app';
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import * as kbnTestServer from '../../../../test_utils/kbn_server';
|
||||
import { Root } from '../../root';
|
||||
|
||||
const { startES } = kbnTestServer.createTestServers({
|
||||
adjustTimeout: (t: number) => jest.setTimeout(t),
|
||||
});
|
||||
let esServer: kbnTestServer.TestElasticsearchUtils;
|
||||
|
||||
describe('default route provider', () => {
|
||||
let root: Root;
|
||||
|
||||
beforeAll(async () => {
|
||||
esServer = await startES();
|
||||
root = kbnTestServer.createRootWithCorePlugins({
|
||||
server: {
|
||||
basePath: '/hello',
|
||||
},
|
||||
});
|
||||
|
||||
await root.setup();
|
||||
await root.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await esServer.stop();
|
||||
await root.shutdown();
|
||||
});
|
||||
|
||||
it('redirects to the configured default route respecting basePath', async function() {
|
||||
const { status, header } = await kbnTestServer.request.get(root, '/');
|
||||
|
||||
expect(status).toEqual(302);
|
||||
expect(header).toMatchObject({
|
||||
location: '/hello/app/kibana',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores invalid values', async function() {
|
||||
const invalidRoutes = [
|
||||
'http://not-your-kibana.com',
|
||||
'///example.com',
|
||||
'//example.com',
|
||||
' //example.com',
|
||||
];
|
||||
|
||||
for (const url of invalidRoutes) {
|
||||
await kbnTestServer.request
|
||||
.post(root, '/api/kibana/settings/defaultRoute')
|
||||
.send({ value: url })
|
||||
.expect(400);
|
||||
}
|
||||
|
||||
const { status, header } = await kbnTestServer.request.get(root, '/');
|
||||
expect(status).toEqual(302);
|
||||
expect(header).toMatchObject({
|
||||
location: '/hello/app/kibana',
|
||||
});
|
||||
});
|
||||
|
||||
it('consumes valid values', async function() {
|
||||
await kbnTestServer.request
|
||||
.post(root, '/api/kibana/settings/defaultRoute')
|
||||
.send({ value: '/valid' })
|
||||
.expect(200);
|
||||
|
||||
const { status, header } = await kbnTestServer.request.get(root, '/');
|
||||
expect(status).toEqual(302);
|
||||
expect(header).toMatchObject({
|
||||
location: '/hello/valid',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -250,6 +250,7 @@ export {
|
|||
export {
|
||||
IUiSettingsClient,
|
||||
UiSettingsParams,
|
||||
PublicUiSettingsParams,
|
||||
UiSettingsType,
|
||||
UiSettingsServiceSetup,
|
||||
UiSettingsServiceStart,
|
||||
|
|
|
@ -998,7 +998,7 @@ export interface IScopedRenderingClient {
|
|||
export interface IUiSettingsClient {
|
||||
get: <T = any>(key: string) => Promise<T>;
|
||||
getAll: <T = any>() => Promise<Record<string, T>>;
|
||||
getRegistered: () => Readonly<Record<string, UiSettingsParams>>;
|
||||
getRegistered: () => Readonly<Record<string, PublicUiSettingsParams>>;
|
||||
getUserProvided: <T = any>() => Promise<Record<string, UserProvidedValues<T>>>;
|
||||
isOverridden: (key: string) => boolean;
|
||||
remove: (key: string) => Promise<void>;
|
||||
|
@ -1443,6 +1443,9 @@ export interface PluginsServiceStart {
|
|||
contracts: Map<PluginName, unknown>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type PublicUiSettingsParams = Omit<UiSettingsParams, 'schema'>;
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "RecursiveReadonlyArray" needs to be exported by the entry point index.d.ts
|
||||
//
|
||||
// @public (undocumented)
|
||||
|
@ -2284,7 +2287,7 @@ export interface StringValidationRegexString {
|
|||
}
|
||||
|
||||
// @public
|
||||
export interface UiSettingsParams {
|
||||
export interface UiSettingsParams<T = unknown> {
|
||||
category?: string[];
|
||||
deprecation?: DeprecationSettings;
|
||||
description?: string;
|
||||
|
@ -2293,10 +2296,12 @@ export interface UiSettingsParams {
|
|||
options?: string[];
|
||||
readonly?: boolean;
|
||||
requiresPageReload?: boolean;
|
||||
// (undocumented)
|
||||
schema: Type<T>;
|
||||
type?: UiSettingsType;
|
||||
// (undocumented)
|
||||
validation?: ImageValidation | StringValidation;
|
||||
value?: SavedObjectAttribute;
|
||||
value?: T;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
|
|
|
@ -26,8 +26,9 @@ import {
|
|||
RawConfigurationProvider,
|
||||
coreDeprecationProvider,
|
||||
} from './config';
|
||||
import { CoreApp } from './core_app';
|
||||
import { ElasticsearchService } from './elasticsearch';
|
||||
import { HttpService, InternalHttpServiceSetup } from './http';
|
||||
import { HttpService } from './http';
|
||||
import { RenderingService, RenderingServiceSetup } from './rendering';
|
||||
import { LegacyService, ensureValidConfiguration } from './legacy';
|
||||
import { Logger, LoggerFactory } from './logging';
|
||||
|
@ -69,6 +70,7 @@ export class Server {
|
|||
private readonly uiSettings: UiSettingsService;
|
||||
private readonly uuid: UuidService;
|
||||
private readonly metrics: MetricsService;
|
||||
private readonly coreApp: CoreApp;
|
||||
|
||||
private coreStart?: InternalCoreStart;
|
||||
|
||||
|
@ -92,6 +94,7 @@ export class Server {
|
|||
this.capabilities = new CapabilitiesService(core);
|
||||
this.uuid = new UuidService(core);
|
||||
this.metrics = new MetricsService(core);
|
||||
this.coreApp = new CoreApp(core);
|
||||
}
|
||||
|
||||
public async setup() {
|
||||
|
@ -122,8 +125,6 @@ export class Server {
|
|||
context: contextServiceSetup,
|
||||
});
|
||||
|
||||
this.registerDefaultRoute(httpSetup);
|
||||
|
||||
const capabilitiesSetup = this.capabilities.setup({ http: httpSetup });
|
||||
|
||||
const elasticsearchServiceSetup = await this.elasticsearch.setup({
|
||||
|
@ -168,6 +169,7 @@ export class Server {
|
|||
});
|
||||
|
||||
this.registerCoreContext(coreSetup, renderingSetup);
|
||||
this.coreApp.setup(coreSetup);
|
||||
|
||||
return coreSetup;
|
||||
}
|
||||
|
@ -216,13 +218,6 @@ export class Server {
|
|||
await this.metrics.stop();
|
||||
}
|
||||
|
||||
private registerDefaultRoute(httpSetup: InternalHttpServiceSetup) {
|
||||
const router = httpSetup.createRouter('/core');
|
||||
router.get({ path: '/', validate: false }, async (context, req, res) =>
|
||||
res.ok({ body: { version: '0.0.1' } })
|
||||
);
|
||||
}
|
||||
|
||||
private registerCoreContext(coreSetup: InternalCoreSetup, rendering: RenderingServiceSetup) {
|
||||
coreSetup.http.registerRouteHandlerContext(
|
||||
coreId,
|
||||
|
|
|
@ -27,6 +27,7 @@ export {
|
|||
UiSettingsServiceStart,
|
||||
IUiSettingsClient,
|
||||
UiSettingsParams,
|
||||
PublicUiSettingsParams,
|
||||
InternalUiSettingsServiceSetup,
|
||||
InternalUiSettingsServiceStart,
|
||||
UiSettingsType,
|
||||
|
|
66
src/core/server/ui_settings/integration_tests/routes.test.ts
Normal file
66
src/core/server/ui_settings/integration_tests/routes.test.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import * as kbnTestServer from '../../../../test_utils/kbn_server';
|
||||
|
||||
describe('ui settings service', () => {
|
||||
describe('routes', () => {
|
||||
let root: ReturnType<typeof kbnTestServer.createRoot>;
|
||||
beforeAll(async () => {
|
||||
root = kbnTestServer.createRoot();
|
||||
|
||||
const { uiSettings } = await root.setup();
|
||||
uiSettings.register({
|
||||
custom: {
|
||||
value: '42',
|
||||
schema: schema.string(),
|
||||
},
|
||||
});
|
||||
|
||||
await root.start();
|
||||
}, 30000);
|
||||
afterAll(async () => await root.shutdown());
|
||||
|
||||
describe('set', () => {
|
||||
it('validates value', async () => {
|
||||
const response = await kbnTestServer.request
|
||||
.post(root, '/api/kibana/settings/custom')
|
||||
.send({ value: 100 })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.message).toBe(
|
||||
'[validation [custom]]: expected value of type [string] but got [number]'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('set many', () => {
|
||||
it('validates value', async () => {
|
||||
const response = await kbnTestServer.request
|
||||
.post(root, '/api/kibana/settings')
|
||||
.send({ changes: { custom: 100, foo: 'bar' } })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.message).toBe(
|
||||
'[validation [custom]]: expected value of type [string] but got [number]'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -16,7 +16,7 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { schema, ValidationError } from '@kbn/config-schema';
|
||||
|
||||
import { IRouter } from '../../http';
|
||||
import { SavedObjectsErrorHelpers } from '../../saved_objects';
|
||||
|
@ -56,7 +56,7 @@ export function registerSetRoute(router: IRouter) {
|
|||
});
|
||||
}
|
||||
|
||||
if (error instanceof CannotOverrideError) {
|
||||
if (error instanceof CannotOverrideError || error instanceof ValidationError) {
|
||||
return response.badRequest({ body: error });
|
||||
}
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { schema, ValidationError } from '@kbn/config-schema';
|
||||
|
||||
import { IRouter } from '../../http';
|
||||
import { SavedObjectsErrorHelpers } from '../../saved_objects';
|
||||
|
@ -50,7 +50,7 @@ export function registerSetManyRoute(router: IRouter) {
|
|||
});
|
||||
}
|
||||
|
||||
if (error instanceof CannotOverrideError) {
|
||||
if (error instanceof CannotOverrideError || error instanceof ValidationError) {
|
||||
return response.badRequest({ body: error });
|
||||
}
|
||||
|
||||
|
|
|
@ -17,9 +17,10 @@
|
|||
* under the License.
|
||||
*/
|
||||
import { SavedObjectsClientContract } from '../saved_objects/types';
|
||||
import { UiSettingsParams, UserProvidedValues } from '../../types';
|
||||
import { UiSettingsParams, UserProvidedValues, PublicUiSettingsParams } from '../../types';
|
||||
export {
|
||||
UiSettingsParams,
|
||||
PublicUiSettingsParams,
|
||||
StringValidationRegexString,
|
||||
StringValidationRegex,
|
||||
StringValidation,
|
||||
|
@ -41,7 +42,7 @@ export interface IUiSettingsClient {
|
|||
/**
|
||||
* Returns registered uiSettings values {@link UiSettingsParams}
|
||||
*/
|
||||
getRegistered: () => Readonly<Record<string, UiSettingsParams>>;
|
||||
getRegistered: () => Readonly<Record<string, PublicUiSettingsParams>>;
|
||||
/**
|
||||
* Retrieves uiSettings values set by the user with fallbacks to default values if not specified.
|
||||
*/
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
*/
|
||||
|
||||
import Chance from 'chance';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
import { loggingServiceMock } from '../logging/logging_service.mock';
|
||||
import { createOrUpgradeSavedConfigMock } from './create_or_upgrade_saved_config/create_or_upgrade_saved_config.test.mock';
|
||||
|
@ -145,6 +146,22 @@ describe('ui settings', () => {
|
|||
expect(error.message).toBe('Unable to update "foo" because it is overridden');
|
||||
}
|
||||
});
|
||||
|
||||
it('validates value if a schema presents', async () => {
|
||||
const defaults = { foo: { schema: schema.string() } };
|
||||
const { uiSettings, savedObjectsClient } = setup({ defaults });
|
||||
|
||||
await expect(
|
||||
uiSettings.setMany({
|
||||
bar: 2,
|
||||
foo: 1,
|
||||
})
|
||||
).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [validation [foo]]: expected value of type [string] but got [number]]`
|
||||
);
|
||||
|
||||
expect(savedObjectsClient.update).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#set()', () => {
|
||||
|
@ -163,6 +180,17 @@ describe('ui settings', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('validates value if a schema presents', async () => {
|
||||
const defaults = { foo: { schema: schema.string() } };
|
||||
const { uiSettings, savedObjectsClient } = setup({ defaults });
|
||||
|
||||
await expect(uiSettings.set('foo', 1)).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [validation [foo]]: expected value of type [string] but got [number]]`
|
||||
);
|
||||
|
||||
expect(savedObjectsClient.update).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('throws CannotOverrideError if the key is overridden', async () => {
|
||||
const { uiSettings } = setup({
|
||||
overrides: {
|
||||
|
@ -193,6 +221,20 @@ describe('ui settings', () => {
|
|||
expect(savedObjectsClient.update).toHaveBeenCalledWith(TYPE, ID, { one: null });
|
||||
});
|
||||
|
||||
it('does not fail validation', async () => {
|
||||
const defaults = {
|
||||
foo: {
|
||||
schema: schema.string(),
|
||||
value: '1',
|
||||
},
|
||||
};
|
||||
const { uiSettings, savedObjectsClient } = setup({ defaults });
|
||||
|
||||
await uiSettings.remove('foo');
|
||||
|
||||
expect(savedObjectsClient.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws CannotOverrideError if the key is overridden', async () => {
|
||||
const { uiSettings } = setup({
|
||||
overrides: {
|
||||
|
@ -235,6 +277,20 @@ describe('ui settings', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not fail validation', async () => {
|
||||
const defaults = {
|
||||
foo: {
|
||||
schema: schema.string(),
|
||||
value: '1',
|
||||
},
|
||||
};
|
||||
const { uiSettings, savedObjectsClient } = setup({ defaults });
|
||||
|
||||
await uiSettings.removeMany(['foo', 'bar']);
|
||||
|
||||
expect(savedObjectsClient.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws CannotOverrideError if any key is overridden', async () => {
|
||||
const { uiSettings } = setup({
|
||||
overrides: {
|
||||
|
@ -256,7 +312,13 @@ describe('ui settings', () => {
|
|||
const value = chance.word();
|
||||
const defaults = { key: { value } };
|
||||
const { uiSettings } = setup({ defaults });
|
||||
expect(uiSettings.getRegistered()).toBe(defaults);
|
||||
expect(uiSettings.getRegistered()).toEqual(defaults);
|
||||
});
|
||||
it('does not leak validation schema outside', () => {
|
||||
const value = chance.word();
|
||||
const defaults = { key: { value, schema: schema.string() } };
|
||||
const { uiSettings } = setup({ defaults });
|
||||
expect(uiSettings.getRegistered()).toStrictEqual({ key: { value } });
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -274,7 +336,7 @@ describe('ui settings', () => {
|
|||
const { uiSettings } = setup({ esDocSource });
|
||||
const result = await uiSettings.getUserProvided();
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(result).toStrictEqual({
|
||||
user: {
|
||||
userValue: 'customized',
|
||||
},
|
||||
|
@ -286,7 +348,7 @@ describe('ui settings', () => {
|
|||
const { uiSettings } = setup({ esDocSource });
|
||||
const result = await uiSettings.getUserProvided();
|
||||
|
||||
expect(result).toEqual({
|
||||
expect(result).toStrictEqual({
|
||||
user: {
|
||||
userValue: 'customized',
|
||||
},
|
||||
|
@ -296,6 +358,32 @@ describe('ui settings', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('ignores user-configured value if it fails validation', async () => {
|
||||
const esDocSource = { user: 'foo', id: 'bar' };
|
||||
const defaults = {
|
||||
id: {
|
||||
value: 42,
|
||||
schema: schema.number(),
|
||||
},
|
||||
};
|
||||
const { uiSettings } = setup({ esDocSource, defaults });
|
||||
const result = await uiSettings.getUserProvided();
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
user: {
|
||||
userValue: 'foo',
|
||||
},
|
||||
});
|
||||
|
||||
expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
"Ignore invalid UiSettings value. Error: [validation [id]]: expected value of type [number] but got [string].",
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('automatically creates the savedConfig if it is missing and returns empty object', async () => {
|
||||
const { uiSettings, savedObjectsClient, createOrUpgradeSavedConfig } = setup();
|
||||
savedObjectsClient.get = jest
|
||||
|
@ -303,7 +391,7 @@ describe('ui settings', () => {
|
|||
.mockRejectedValueOnce(SavedObjectsClient.errors.createGenericNotFoundError())
|
||||
.mockResolvedValueOnce({ attributes: {} });
|
||||
|
||||
expect(await uiSettings.getUserProvided()).toEqual({});
|
||||
expect(await uiSettings.getUserProvided()).toStrictEqual({});
|
||||
|
||||
expect(savedObjectsClient.get).toHaveBeenCalledTimes(2);
|
||||
|
||||
|
@ -320,7 +408,7 @@ describe('ui settings', () => {
|
|||
SavedObjectsClient.errors.createGenericNotFoundError()
|
||||
);
|
||||
|
||||
expect(await uiSettings.getUserProvided()).toEqual({ foo: { userValue: 'bar ' } });
|
||||
expect(await uiSettings.getUserProvided()).toStrictEqual({ foo: { userValue: 'bar ' } });
|
||||
});
|
||||
|
||||
it('returns an empty object on Forbidden responses', async () => {
|
||||
|
@ -329,7 +417,7 @@ describe('ui settings', () => {
|
|||
const error = SavedObjectsClient.errors.decorateForbiddenError(new Error());
|
||||
savedObjectsClient.get.mockRejectedValue(error);
|
||||
|
||||
expect(await uiSettings.getUserProvided()).toEqual({});
|
||||
expect(await uiSettings.getUserProvided()).toStrictEqual({});
|
||||
expect(createOrUpgradeSavedConfig).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
|
@ -339,7 +427,7 @@ describe('ui settings', () => {
|
|||
const error = SavedObjectsClient.errors.decorateEsUnavailableError(new Error());
|
||||
savedObjectsClient.get.mockRejectedValue(error);
|
||||
|
||||
expect(await uiSettings.getUserProvided()).toEqual({});
|
||||
expect(await uiSettings.getUserProvided()).toStrictEqual({});
|
||||
expect(createOrUpgradeSavedConfig).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
|
@ -382,7 +470,7 @@ describe('ui settings', () => {
|
|||
};
|
||||
|
||||
const { uiSettings } = setup({ esDocSource, overrides });
|
||||
expect(await uiSettings.getUserProvided()).toEqual({
|
||||
expect(await uiSettings.getUserProvided()).toStrictEqual({
|
||||
user: {
|
||||
userValue: 'customized',
|
||||
},
|
||||
|
@ -404,15 +492,40 @@ describe('ui settings', () => {
|
|||
expect(savedObjectsClient.get).toHaveBeenCalledWith(TYPE, ID);
|
||||
});
|
||||
|
||||
it(`returns defaults when es doc is empty`, async () => {
|
||||
it('returns defaults when es doc is empty', async () => {
|
||||
const esDocSource = {};
|
||||
const defaults = { foo: { value: 'bar' } };
|
||||
const { uiSettings } = setup({ esDocSource, defaults });
|
||||
expect(await uiSettings.getAll()).toEqual({
|
||||
expect(await uiSettings.getAll()).toStrictEqual({
|
||||
foo: 'bar',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores user-configured value if it fails validation', async () => {
|
||||
const esDocSource = { user: 'foo', id: 'bar' };
|
||||
const defaults = {
|
||||
id: {
|
||||
value: 42,
|
||||
schema: schema.number(),
|
||||
},
|
||||
};
|
||||
const { uiSettings } = setup({ esDocSource, defaults });
|
||||
const result = await uiSettings.getAll();
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
id: 42,
|
||||
user: 'foo',
|
||||
});
|
||||
|
||||
expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
"Ignore invalid UiSettings value. Error: [validation [id]]: expected value of type [number] but got [string].",
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it(`merges user values, including ones without defaults, into key value pairs`, async () => {
|
||||
const esDocSource = {
|
||||
foo: 'user-override',
|
||||
|
@ -427,7 +540,7 @@ describe('ui settings', () => {
|
|||
|
||||
const { uiSettings } = setup({ esDocSource, defaults });
|
||||
|
||||
expect(await uiSettings.getAll()).toEqual({
|
||||
expect(await uiSettings.getAll()).toStrictEqual({
|
||||
foo: 'user-override',
|
||||
bar: 'user-provided',
|
||||
});
|
||||
|
@ -451,7 +564,7 @@ describe('ui settings', () => {
|
|||
|
||||
const { uiSettings } = setup({ esDocSource, defaults, overrides });
|
||||
|
||||
expect(await uiSettings.getAll()).toEqual({
|
||||
expect(await uiSettings.getAll()).toStrictEqual({
|
||||
foo: 'bax',
|
||||
bar: 'user-provided',
|
||||
});
|
||||
|
@ -518,6 +631,28 @@ describe('ui settings', () => {
|
|||
|
||||
expect(await uiSettings.get('dateFormat')).toBe('foo');
|
||||
});
|
||||
|
||||
it('returns the default value if user-configured value fails validation', async () => {
|
||||
const esDocSource = { id: 'bar' };
|
||||
const defaults = {
|
||||
id: {
|
||||
value: 42,
|
||||
schema: schema.number(),
|
||||
},
|
||||
};
|
||||
|
||||
const { uiSettings } = setup({ esDocSource, defaults });
|
||||
|
||||
expect(await uiSettings.get('id')).toBe(42);
|
||||
|
||||
expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
"Ignore invalid UiSettings value. Error: [validation [id]]: expected value of type [number] but got [string].",
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isOverridden()', () => {
|
||||
|
|
|
@ -16,13 +16,13 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { defaultsDeep } from 'lodash';
|
||||
import { defaultsDeep, omit } from 'lodash';
|
||||
|
||||
import { SavedObjectsErrorHelpers } from '../saved_objects';
|
||||
import { SavedObjectsClientContract } from '../saved_objects/types';
|
||||
import { Logger } from '../logging';
|
||||
import { createOrUpgradeSavedConfig } from './create_or_upgrade_saved_config';
|
||||
import { IUiSettingsClient, UiSettingsParams } from './types';
|
||||
import { IUiSettingsClient, UiSettingsParams, PublicUiSettingsParams } from './types';
|
||||
import { CannotOverrideError } from './ui_settings_errors';
|
||||
|
||||
export interface UiSettingsServiceOptions {
|
||||
|
@ -40,14 +40,14 @@ interface ReadOptions {
|
|||
autoCreateOrUpgradeIfMissing?: boolean;
|
||||
}
|
||||
|
||||
interface UserProvidedValue<T = any> {
|
||||
interface UserProvidedValue<T = unknown> {
|
||||
userValue?: T;
|
||||
isOverridden?: boolean;
|
||||
}
|
||||
|
||||
type UiSettingsRawValue = UiSettingsParams & UserProvidedValue;
|
||||
|
||||
type UserProvided<T = any> = Record<string, UserProvidedValue<T>>;
|
||||
type UserProvided<T = unknown> = Record<string, UserProvidedValue<T>>;
|
||||
type UiSettingsRaw = Record<string, UiSettingsRawValue>;
|
||||
|
||||
export class UiSettingsClient implements IUiSettingsClient {
|
||||
|
@ -72,7 +72,11 @@ export class UiSettingsClient implements IUiSettingsClient {
|
|||
}
|
||||
|
||||
getRegistered() {
|
||||
return this.defaults;
|
||||
const copiedDefaults: Record<string, PublicUiSettingsParams> = {};
|
||||
for (const [key, value] of Object.entries(this.defaults)) {
|
||||
copiedDefaults[key] = omit(value, 'schema');
|
||||
}
|
||||
return copiedDefaults;
|
||||
}
|
||||
|
||||
async get<T = any>(key: string): Promise<T> {
|
||||
|
@ -90,29 +94,21 @@ export class UiSettingsClient implements IUiSettingsClient {
|
|||
}, {} as Record<string, T>);
|
||||
}
|
||||
|
||||
async getUserProvided<T = any>(): Promise<UserProvided<T>> {
|
||||
const userProvided: UserProvided = {};
|
||||
|
||||
// write the userValue for each key stored in the saved object that is not overridden
|
||||
for (const [key, userValue] of Object.entries(await this.read())) {
|
||||
if (userValue !== null && !this.isOverridden(key)) {
|
||||
userProvided[key] = {
|
||||
userValue,
|
||||
};
|
||||
}
|
||||
}
|
||||
async getUserProvided<T = unknown>(): Promise<UserProvided<T>> {
|
||||
const userProvided: UserProvided<T> = this.onReadHook<T>(await this.read());
|
||||
|
||||
// write all overridden keys, dropping the userValue is override is null and
|
||||
// adding keys for overrides that are not in saved object
|
||||
for (const [key, userValue] of Object.entries(this.overrides)) {
|
||||
for (const [key, value] of Object.entries(this.overrides)) {
|
||||
userProvided[key] =
|
||||
userValue === null ? { isOverridden: true } : { isOverridden: true, userValue };
|
||||
value === null ? { isOverridden: true } : { isOverridden: true, userValue: value };
|
||||
}
|
||||
|
||||
return userProvided;
|
||||
}
|
||||
|
||||
async setMany(changes: Record<string, any>) {
|
||||
this.onWriteHook(changes);
|
||||
await this.write({ changes });
|
||||
}
|
||||
|
||||
|
@ -147,6 +143,43 @@ export class UiSettingsClient implements IUiSettingsClient {
|
|||
return defaultsDeep(userProvided, this.defaults);
|
||||
}
|
||||
|
||||
private validateKey(key: string, value: unknown) {
|
||||
const definition = this.defaults[key];
|
||||
if (value === null || definition === undefined) return;
|
||||
if (definition.schema) {
|
||||
definition.schema.validate(value, {}, `validation [${key}]`);
|
||||
}
|
||||
}
|
||||
|
||||
private onWriteHook(changes: Record<string, unknown>) {
|
||||
for (const key of Object.keys(changes)) {
|
||||
this.assertUpdateAllowed(key);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
this.validateKey(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private onReadHook<T = unknown>(values: Record<string, unknown>) {
|
||||
// write the userValue for each key stored in the saved object that is not overridden
|
||||
// validate value read from saved objects as it can be changed via SO API
|
||||
const filteredValues: UserProvided<T> = {};
|
||||
for (const [key, userValue] of Object.entries(values)) {
|
||||
if (userValue === null || this.isOverridden(key)) continue;
|
||||
try {
|
||||
this.validateKey(key, userValue);
|
||||
filteredValues[key] = {
|
||||
userValue: userValue as T,
|
||||
};
|
||||
} catch (error) {
|
||||
this.log.warn(`Ignore invalid UiSettings value. ${error}.`);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredValues;
|
||||
}
|
||||
|
||||
private async write({
|
||||
changes,
|
||||
autoCreateOrUpgradeIfMissing = true,
|
||||
|
@ -154,10 +187,6 @@ export class UiSettingsClient implements IUiSettingsClient {
|
|||
changes: Record<string, any>;
|
||||
autoCreateOrUpgradeIfMissing?: boolean;
|
||||
}) {
|
||||
for (const key of Object.keys(changes)) {
|
||||
this.assertUpdateAllowed(key);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.savedObjectsClient.update(this.type, this.id, changes);
|
||||
} catch (error) {
|
||||
|
|
|
@ -17,6 +17,8 @@
|
|||
* under the License.
|
||||
*/
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
import { MockUiSettingsClientConstructor } from './ui_settings_service.test.mock';
|
||||
import { UiSettingsService, SetupDeps } from './ui_settings_service';
|
||||
import { httpServiceMock } from '../http/http_service.mock';
|
||||
|
@ -35,6 +37,7 @@ const defaults = {
|
|||
value: 'bar',
|
||||
category: [],
|
||||
description: '',
|
||||
schema: schema.string(),
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -104,6 +107,45 @@ describe('uiSettings', () => {
|
|||
});
|
||||
|
||||
describe('#start', () => {
|
||||
describe('validation', () => {
|
||||
it('validates registered definitions', async () => {
|
||||
const { register } = await service.setup(setupDeps);
|
||||
register({
|
||||
custom: {
|
||||
value: 42,
|
||||
schema: schema.string(),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.start()).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [ui settings defaults [custom]]: expected value of type [string] but got [number]]`
|
||||
);
|
||||
});
|
||||
|
||||
it('validates overrides', async () => {
|
||||
const coreContext = mockCoreContext.create();
|
||||
coreContext.configService.atPath.mockReturnValueOnce(
|
||||
new BehaviorSubject({
|
||||
overrides: {
|
||||
custom: 42,
|
||||
},
|
||||
})
|
||||
);
|
||||
const customizedService = new UiSettingsService(coreContext);
|
||||
const { register } = await customizedService.setup(setupDeps);
|
||||
register({
|
||||
custom: {
|
||||
value: '42',
|
||||
schema: schema.string(),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(customizedService.start()).rejects.toMatchInlineSnapshot(
|
||||
`[Error: [ui settings overrides [custom]]: expected value of type [string] but got [number]]`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#asScopedToClient', () => {
|
||||
it('passes saved object type "config" to UiSettingsClient', async () => {
|
||||
await service.setup(setupDeps);
|
||||
|
|
|
@ -70,6 +70,9 @@ export class UiSettingsService
|
|||
}
|
||||
|
||||
public async start(): Promise<InternalUiSettingsServiceStart> {
|
||||
this.validatesDefinitions();
|
||||
this.validatesOverrides();
|
||||
|
||||
return {
|
||||
asScopedToClient: this.getScopedClientFactory(),
|
||||
};
|
||||
|
@ -101,4 +104,21 @@ export class UiSettingsService
|
|||
this.uiSettingsDefaults.set(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
private validatesDefinitions() {
|
||||
for (const [key, definition] of this.uiSettingsDefaults) {
|
||||
if (definition.schema) {
|
||||
definition.schema.validate(definition.value, {}, `ui settings defaults [${key}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validatesOverrides() {
|
||||
for (const [key, value] of Object.entries(this.overrides)) {
|
||||
const definition = this.uiSettingsDefaults.get(key);
|
||||
if (definition?.schema) {
|
||||
definition.schema.validate(value, {}, `ui settings overrides [${key}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,8 +16,7 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { SavedObjectAttribute } from './saved_objects';
|
||||
import { Type } from '@kbn/config-schema';
|
||||
|
||||
/**
|
||||
* UI element type to represent the settings.
|
||||
|
@ -49,11 +48,11 @@ export interface DeprecationSettings {
|
|||
* UiSettings parameters defined by the plugins.
|
||||
* @public
|
||||
* */
|
||||
export interface UiSettingsParams {
|
||||
export interface UiSettingsParams<T = unknown> {
|
||||
/** title in the UI */
|
||||
name?: string;
|
||||
/** default value to fall back to if a user doesn't provide any */
|
||||
value?: SavedObjectAttribute;
|
||||
value?: T;
|
||||
/** description provided to a user in UI */
|
||||
description?: string;
|
||||
/** used to group the configured setting in the UI */
|
||||
|
@ -73,10 +72,22 @@ export interface UiSettingsParams {
|
|||
/*
|
||||
* Allows defining a custom validation applicable to value change on the client.
|
||||
* @deprecated
|
||||
* Use schema instead.
|
||||
*/
|
||||
validation?: ImageValidation | StringValidation;
|
||||
/*
|
||||
* Value validation schema
|
||||
* Used to validate value on write and read.
|
||||
*/
|
||||
schema: Type<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sub-set of {@link UiSettingsParams} exposed to the client-side.
|
||||
* @public
|
||||
* */
|
||||
export type PublicUiSettingsParams = Omit<UiSettingsParams, 'schema'>;
|
||||
|
||||
/**
|
||||
* Allows regex objects or a regex string
|
||||
* @public
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { modifyUrl } from './url';
|
||||
import { modifyUrl, isRelativeUrl } from './url';
|
||||
|
||||
describe('modifyUrl()', () => {
|
||||
test('throws an error with invalid input', () => {
|
||||
|
@ -69,3 +69,17 @@ describe('modifyUrl()', () => {
|
|||
).toEqual('mail:localhost');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRelativeUrl()', () => {
|
||||
test('returns "true" for a relative URL', () => {
|
||||
expect(isRelativeUrl('good')).toBe(true);
|
||||
expect(isRelativeUrl('/good')).toBe(true);
|
||||
expect(isRelativeUrl('/good/even/better')).toBe(true);
|
||||
});
|
||||
test('returns "false" for a non-relative URL', () => {
|
||||
expect(isRelativeUrl('http://evil.com')).toBe(false);
|
||||
expect(isRelativeUrl('//evil.com')).toBe(false);
|
||||
expect(isRelativeUrl('///evil.com')).toBe(false);
|
||||
expect(isRelativeUrl(' //evil.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -99,3 +99,19 @@ export function modifyUrl(
|
|||
slashes: modifiedParts.slashes,
|
||||
} as UrlObject);
|
||||
}
|
||||
|
||||
export function isRelativeUrl(candidatePath: string) {
|
||||
// validate that `candidatePath` is not attempting a redirect to somewhere
|
||||
// outside of this Kibana install
|
||||
const all = parseUrl(candidatePath, false /* parseQueryString */, true /* slashesDenoteHost */);
|
||||
const { protocol, hostname, port } = all;
|
||||
// We should explicitly compare `protocol`, `port` and `hostname` to null to make sure these are not
|
||||
// detected in the URL at all. For example `hostname` can be empty string for Node URL parser, but
|
||||
// browser (because of various bwc reasons) processes URL differently (e.g. `///abc.com` - for browser
|
||||
// hostname is `abc.com`, but for Node hostname is an empty string i.e. everything between schema (`//`)
|
||||
// and the first slash that belongs to path.
|
||||
if (protocol !== null || hostname !== null || port !== null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -16,11 +16,13 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import numeralLanguages from '@elastic/numeral/languages';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
import { DEFAULT_QUERY_LANGUAGE } from '../../../plugins/data/common';
|
||||
import { isRelativeUrl } from '../../../core/utils';
|
||||
|
||||
export function getUiSettingDefaults() {
|
||||
const weekdays = moment.weekdays().slice();
|
||||
|
@ -67,17 +69,23 @@ export function getUiSettingDefaults() {
|
|||
defaultMessage: 'Default route',
|
||||
}),
|
||||
value: '/app/kibana',
|
||||
validation: {
|
||||
regexString: '^/',
|
||||
message: i18n.translate('kbn.advancedSettings.defaultRoute.defaultRouteValidationMessage', {
|
||||
defaultMessage: 'The route must start with a slash ("/")',
|
||||
}),
|
||||
},
|
||||
schema: schema.string({
|
||||
validate(value) {
|
||||
if (!value.startsWith('/') || !isRelativeUrl(value)) {
|
||||
return i18n.translate(
|
||||
'kbn.advancedSettings.defaultRoute.defaultRouteIsRelativeValidationMessage',
|
||||
{
|
||||
defaultMessage: 'Must be a relative URL.',
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
description: i18n.translate('kbn.advancedSettings.defaultRoute.defaultRouteText', {
|
||||
defaultMessage:
|
||||
'This setting specifies the default route when opening Kibana. ' +
|
||||
'You can use this setting to modify the landing page when opening Kibana. ' +
|
||||
'The route must start with a slash ("/").',
|
||||
'The route must be a relative URL.',
|
||||
}),
|
||||
},
|
||||
'query:queryString:options': {
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Type } from '@kbn/config-schema';
|
||||
import pkg from '../../../../package.json';
|
||||
|
||||
export const createTestEntryTemplate = defaultUiSettings => bundle => `
|
||||
|
@ -87,7 +87,14 @@ const coreSystem = new CoreSystem({
|
|||
buildNum: 1234,
|
||||
devMode: true,
|
||||
uiSettings: {
|
||||
defaults: ${JSON.stringify(defaultUiSettings, null, 2)
|
||||
defaults: ${JSON.stringify(
|
||||
defaultUiSettings,
|
||||
(key, value) => {
|
||||
if (value instanceof Type) return null;
|
||||
return value;
|
||||
},
|
||||
2
|
||||
)
|
||||
.split('\n')
|
||||
.join('\n ')},
|
||||
user: {}
|
||||
|
|
|
@ -24,15 +24,12 @@ import Boom from 'boom';
|
|||
|
||||
import { registerHapiPlugins } from './register_hapi_plugins';
|
||||
import { setupBasePathProvider } from './setup_base_path_provider';
|
||||
import { setupDefaultRouteProvider } from './setup_default_route_provider';
|
||||
|
||||
export default async function(kbnServer, server, config) {
|
||||
server = kbnServer.server;
|
||||
|
||||
setupBasePathProvider(kbnServer);
|
||||
|
||||
setupDefaultRouteProvider(server);
|
||||
|
||||
await registerHapiPlugins(server);
|
||||
|
||||
// provide a simple way to expose static directories
|
||||
|
@ -60,14 +57,6 @@ export default async function(kbnServer, server, config) {
|
|||
});
|
||||
});
|
||||
|
||||
server.route({
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
async handler(req, h) {
|
||||
return h.redirect(await req.getDefaultRoute());
|
||||
},
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: 'GET',
|
||||
path: '/{p*}',
|
||||
|
|
|
@ -1,87 +0,0 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
jest.mock('../../../ui/ui_settings/ui_settings_mixin', () => {
|
||||
return jest.fn();
|
||||
});
|
||||
|
||||
import * as kbnTestServer from '../../../../test_utils/kbn_server';
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { Root } from '../../../../core/server/root';
|
||||
|
||||
let mockDefaultRouteSetting: any = '';
|
||||
|
||||
describe('default route provider', () => {
|
||||
let root: Root;
|
||||
beforeAll(async () => {
|
||||
root = kbnTestServer.createRoot({ migrations: { skip: true } });
|
||||
|
||||
await root.setup();
|
||||
await root.start();
|
||||
|
||||
const kbnServer = kbnTestServer.getKbnServer(root);
|
||||
|
||||
kbnServer.server.decorate('request', 'getUiSettingsService', function() {
|
||||
return {
|
||||
get: (key: string) => {
|
||||
if (key === 'defaultRoute') {
|
||||
return Promise.resolve(mockDefaultRouteSetting);
|
||||
}
|
||||
throw Error(`unsupported ui setting: ${key}`);
|
||||
},
|
||||
getRegistered: () => {
|
||||
return {
|
||||
defaultRoute: {
|
||||
value: '/app/kibana',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => await root.shutdown());
|
||||
|
||||
it('redirects to the configured default route', async function() {
|
||||
mockDefaultRouteSetting = '/app/some/default/route';
|
||||
|
||||
const { status, header } = await kbnTestServer.request.get(root, '/');
|
||||
expect(status).toEqual(302);
|
||||
expect(header).toMatchObject({
|
||||
location: '/app/some/default/route',
|
||||
});
|
||||
});
|
||||
|
||||
const invalidRoutes = [
|
||||
'http://not-your-kibana.com',
|
||||
'///example.com',
|
||||
'//example.com',
|
||||
' //example.com',
|
||||
];
|
||||
for (const route of invalidRoutes) {
|
||||
it(`falls back to /app/kibana when the configured route (${route}) is not a valid relative path`, async function() {
|
||||
mockDefaultRouteSetting = route;
|
||||
|
||||
const { status, header } = await kbnTestServer.request.get(root, '/');
|
||||
expect(status).toEqual(302);
|
||||
expect(header).toMatchObject({
|
||||
location: '/app/kibana',
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
|
@ -1,54 +0,0 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import * as kbnTestServer from '../../../../test_utils/kbn_server';
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { Root } from '../../../../core/server/root';
|
||||
|
||||
describe('default route provider', () => {
|
||||
let root: Root;
|
||||
|
||||
afterEach(async () => await root.shutdown());
|
||||
|
||||
it('redirects to the configured default route', async function() {
|
||||
root = kbnTestServer.createRoot({
|
||||
server: {
|
||||
defaultRoute: '/app/some/default/route',
|
||||
},
|
||||
migrations: { skip: true },
|
||||
});
|
||||
|
||||
await root.setup();
|
||||
await root.start();
|
||||
|
||||
const kbnServer = kbnTestServer.getKbnServer(root);
|
||||
|
||||
kbnServer.server.decorate('request', 'getSavedObjectsClient', function() {
|
||||
return {
|
||||
get: (type: string, id: string) => ({ attributes: {} }),
|
||||
};
|
||||
});
|
||||
|
||||
const { status, header } = await kbnTestServer.request.get(root, '/');
|
||||
|
||||
expect(status).toEqual(302);
|
||||
expect(header).toMatchObject({
|
||||
location: '/app/some/default/route',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,74 +0,0 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Legacy } from 'kibana';
|
||||
import { parse } from 'url';
|
||||
|
||||
export function setupDefaultRouteProvider(server: Legacy.Server) {
|
||||
server.decorate('request', 'getDefaultRoute', async function() {
|
||||
// @ts-ignore
|
||||
const request: Legacy.Request = this;
|
||||
|
||||
const serverBasePath: string = server.config().get('server.basePath');
|
||||
|
||||
const uiSettings = request.getUiSettingsService();
|
||||
|
||||
const defaultRoute = await uiSettings.get<string>('defaultRoute');
|
||||
const qualifiedDefaultRoute = `${request.getBasePath()}${defaultRoute}`;
|
||||
|
||||
if (isRelativePath(qualifiedDefaultRoute, serverBasePath)) {
|
||||
return qualifiedDefaultRoute;
|
||||
} else {
|
||||
server.log(
|
||||
['http', 'warn'],
|
||||
`Ignoring configured default route of '${defaultRoute}', as it is malformed.`
|
||||
);
|
||||
|
||||
const fallbackRoute = uiSettings.getRegistered().defaultRoute.value;
|
||||
|
||||
const qualifiedFallbackRoute = `${request.getBasePath()}${fallbackRoute}`;
|
||||
return qualifiedFallbackRoute;
|
||||
}
|
||||
});
|
||||
|
||||
function isRelativePath(candidatePath: string, basePath = '') {
|
||||
// validate that `candidatePath` is not attempting a redirect to somewhere
|
||||
// outside of this Kibana install
|
||||
const { protocol, hostname, port, pathname } = parse(
|
||||
candidatePath,
|
||||
false /* parseQueryString */,
|
||||
true /* slashesDenoteHost */
|
||||
);
|
||||
|
||||
// We should explicitly compare `protocol`, `port` and `hostname` to null to make sure these are not
|
||||
// detected in the URL at all. For example `hostname` can be empty string for Node URL parser, but
|
||||
// browser (because of various bwc reasons) processes URL differently (e.g. `///abc.com` - for browser
|
||||
// hostname is `abc.com`, but for Node hostname is an empty string i.e. everything between schema (`//`)
|
||||
// and the first slash that belongs to path.
|
||||
if (protocol !== null || hostname !== null || port !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!String(pathname).startsWith(basePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
1
src/legacy/server/kbn_server.d.ts
vendored
1
src/legacy/server/kbn_server.d.ts
vendored
|
@ -92,7 +92,6 @@ declare module 'hapi' {
|
|||
interface Request {
|
||||
getSavedObjectsClient(options?: SavedObjectsClientProviderOptions): SavedObjectsClientContract;
|
||||
getBasePath(): string;
|
||||
getDefaultRoute(): Promise<string>;
|
||||
getUiSettingsService(): IUiSettingsClient;
|
||||
}
|
||||
|
||||
|
|
|
@ -22,7 +22,11 @@ import { Observable } from 'rxjs';
|
|||
import { ReactWrapper } from 'enzyme';
|
||||
import { mountWithI18nProvider } from 'test_utils/enzyme_helpers';
|
||||
import dedent from 'dedent';
|
||||
import { UiSettingsParams, UserProvidedValues, UiSettingsType } from '../../../../core/public';
|
||||
import {
|
||||
PublicUiSettingsParams,
|
||||
UserProvidedValues,
|
||||
UiSettingsType,
|
||||
} from '../../../../core/public';
|
||||
import { FieldSetting } from './types';
|
||||
import { AdvancedSettingsComponent } from './advanced_settings';
|
||||
import { notificationServiceMock, docLinksServiceMock } from '../../../../core/public/mocks';
|
||||
|
@ -68,7 +72,7 @@ function mockConfig() {
|
|||
remove: (key: string) => Promise.resolve(true),
|
||||
isCustom: (key: string) => false,
|
||||
isOverridden: (key: string) => Boolean(config.getAll()[key].isOverridden),
|
||||
getRegistered: () => ({} as Readonly<Record<string, UiSettingsParams>>),
|
||||
getRegistered: () => ({} as Readonly<Record<string, PublicUiSettingsParams>>),
|
||||
overrideLocalDefault: (key: string, value: any) => {},
|
||||
getUpdate$: () =>
|
||||
new Observable<{
|
||||
|
@ -89,7 +93,7 @@ function mockConfig() {
|
|||
getUpdateErrors$: () => new Observable<Error>(),
|
||||
get: (key: string, defaultOverride?: any): any => config.getAll()[key] || defaultOverride,
|
||||
get$: (key: string) => new Observable<any>(config.get(key)),
|
||||
getAll: (): Readonly<Record<string, UiSettingsParams & UserProvidedValues>> => {
|
||||
getAll: (): Readonly<Record<string, PublicUiSettingsParams & UserProvidedValues>> => {
|
||||
return {
|
||||
'test:array:setting': {
|
||||
...defaultConfig,
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import { UiSettingsParams, StringValidationRegex } from 'src/core/public';
|
||||
import { PublicUiSettingsParams, StringValidationRegex } from 'src/core/public';
|
||||
import expect from '@kbn/expect';
|
||||
import { toEditableConfig } from './to_editable_config';
|
||||
|
||||
|
@ -30,7 +30,7 @@ function invoke({
|
|||
name = 'woah',
|
||||
value = 'forreal',
|
||||
}: {
|
||||
def?: UiSettingsParams & { isOverridden?: boolean };
|
||||
def?: PublicUiSettingsParams & { isOverridden?: boolean };
|
||||
name?: string;
|
||||
value?: any;
|
||||
}) {
|
||||
|
@ -55,7 +55,7 @@ describe('Settings', function() {
|
|||
});
|
||||
|
||||
describe('when given a setting definition object', function() {
|
||||
let def: UiSettingsParams & { isOverridden?: boolean };
|
||||
let def: PublicUiSettingsParams & { isOverridden?: boolean };
|
||||
beforeEach(function() {
|
||||
def = {
|
||||
value: 'the original',
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
UiSettingsParams,
|
||||
PublicUiSettingsParams,
|
||||
UserProvidedValues,
|
||||
StringValidationRegexString,
|
||||
SavedObjectAttribute,
|
||||
|
@ -40,7 +40,7 @@ export function toEditableConfig({
|
|||
isCustom,
|
||||
isOverridden,
|
||||
}: {
|
||||
def: UiSettingsParams & UserProvidedValues<any>;
|
||||
def: PublicUiSettingsParams & UserProvidedValues<any>;
|
||||
name: string;
|
||||
value: SavedObjectAttribute;
|
||||
isCustom: boolean;
|
||||
|
|
|
@ -17,17 +17,12 @@
|
|||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
UiSettingsType,
|
||||
StringValidation,
|
||||
ImageValidation,
|
||||
SavedObjectAttribute,
|
||||
} from '../../../../core/public';
|
||||
import { UiSettingsType, StringValidation, ImageValidation } from '../../../../core/public';
|
||||
|
||||
export interface FieldSetting {
|
||||
displayName: string;
|
||||
name: string;
|
||||
value: SavedObjectAttribute;
|
||||
value: unknown;
|
||||
description?: string;
|
||||
options?: string[];
|
||||
optionLabels?: Record<string, string>;
|
||||
|
@ -36,7 +31,7 @@ export interface FieldSetting {
|
|||
category: string[];
|
||||
ariaName: string;
|
||||
isOverridden: boolean;
|
||||
defVal: SavedObjectAttribute;
|
||||
defVal: unknown;
|
||||
isCustom: boolean;
|
||||
validation?: StringValidation | ImageValidation;
|
||||
readOnly?: boolean;
|
||||
|
|
|
@ -38,6 +38,7 @@ import { Observable } from 'rxjs';
|
|||
import { Plugin as Plugin_2 } from 'src/core/public';
|
||||
import { PluginInitializerContext as PluginInitializerContext_2 } from 'src/core/public';
|
||||
import { PopoverAnchorPosition } from '@elastic/eui';
|
||||
import { PublicUiSettingsParams } from 'src/core/server/types';
|
||||
import React from 'react';
|
||||
import * as React_2 from 'react';
|
||||
import { Required } from '@kbn/utility-types';
|
||||
|
@ -49,7 +50,6 @@ import { SearchResponse as SearchResponse_2 } from 'elasticsearch';
|
|||
import { SimpleSavedObject } from 'src/core/public';
|
||||
import { UiActionsSetup } from 'src/plugins/ui_actions/public';
|
||||
import { UiActionsStart } from 'src/plugins/ui_actions/public';
|
||||
import { UiSettingsParams } from 'src/core/server/types';
|
||||
import { Unit } from '@elastic/datemath';
|
||||
import { UnregisterCallback } from 'history';
|
||||
import { UserProvidedValues } from 'src/core/server/types';
|
||||
|
|
|
@ -50,6 +50,7 @@ const DEFAULTS_SETTINGS = {
|
|||
logging: { silent: true },
|
||||
plugins: {},
|
||||
optimize: { enabled: false },
|
||||
migrations: { skip: true },
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS_WITH_CORE_PLUGINS = {
|
||||
|
|
|
@ -33,6 +33,5 @@ export default function({ loadTestFile }) {
|
|||
loadTestFile(require.resolve('./status'));
|
||||
loadTestFile(require.resolve('./stats'));
|
||||
loadTestFile(require.resolve('./ui_metric'));
|
||||
loadTestFile(require.resolve('./core'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { Plugin, CoreSetup } from 'kibana/server';
|
||||
|
||||
export class UiSettingsPlugin implements Plugin {
|
||||
|
@ -27,6 +27,7 @@ export class UiSettingsPlugin implements Plugin {
|
|||
description: 'just for testing',
|
||||
value: '2',
|
||||
category: ['any'],
|
||||
schema: schema.string(),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -14,7 +14,13 @@ export function initEnterSpaceView(server: Legacy.Server) {
|
|||
path: ENTER_SPACE_PATH,
|
||||
async handler(request, h) {
|
||||
try {
|
||||
return h.redirect(await request.getDefaultRoute());
|
||||
const uiSettings = request.getUiSettingsService();
|
||||
const defaultRoute = await uiSettings.get<string>('defaultRoute');
|
||||
|
||||
const basePath = server.newPlatform.setup.core.http.basePath.get(request);
|
||||
const url = `${basePath}${defaultRoute}`;
|
||||
|
||||
return h.redirect(url);
|
||||
} catch (e) {
|
||||
server.log(['spaces', 'error'], `Error navigating to space: ${e}`);
|
||||
return wrapError(e);
|
||||
|
|
|
@ -833,7 +833,6 @@
|
|||
"kbn.advancedSettings.defaultIndexTitle": "デフォルトのインデックス",
|
||||
"kbn.advancedSettings.defaultRoute.defaultRouteText": "この設定は、Kibana 起動時のデフォルトのルートを設定します。この設定で、Kibana 起動時のランディングページを変更できます。ルートはスラッシュ (\"/\") で始まる必要があります。",
|
||||
"kbn.advancedSettings.defaultRoute.defaultRouteTitle": "デフォルトのルート",
|
||||
"kbn.advancedSettings.defaultRoute.defaultRouteValidationMessage": "ルートはスラッシュ (\"/\") で始まる必要があります。",
|
||||
"kbn.advancedSettings.disableAnimationsText": "Kibana UI の不要なアニメーションをオフにします。変更を適用するにはページを更新してください。",
|
||||
"kbn.advancedSettings.disableAnimationsTitle": "アニメーションを無効にする",
|
||||
"kbn.advancedSettings.discover.aggsTermsSizeText": "「可視化」ボタンをクリックした際に、フィールドドロップダウンやディスカバリサイドバーに可視化される用語の数を設定します。",
|
||||
|
|
|
@ -833,7 +833,6 @@
|
|||
"kbn.advancedSettings.defaultIndexTitle": "默认索引",
|
||||
"kbn.advancedSettings.defaultRoute.defaultRouteText": "此设置指定打开 Kibana 时的默认路由。您可以使用此设置修改打开 Kibana 时的登陆页面。路由必须以正斜杠(“/”)开头。",
|
||||
"kbn.advancedSettings.defaultRoute.defaultRouteTitle": "默认路由",
|
||||
"kbn.advancedSettings.defaultRoute.defaultRouteValidationMessage": "路由必须以正斜杠(“/”)开头",
|
||||
"kbn.advancedSettings.disableAnimationsText": "在 Kibana UI 中关闭所有没有必要的动画。刷新页面以应用更改。",
|
||||
"kbn.advancedSettings.disableAnimationsTitle": "禁用动画",
|
||||
"kbn.advancedSettings.discover.aggsTermsSizeText": "确定在单击“可视化”按钮时将在发现侧边栏的字段下拉列表中可视化多少个词。",
|
||||
|
|
|
@ -10,7 +10,6 @@ export default function enterSpaceFunctonalTests({
|
|||
getPageObjects,
|
||||
}: FtrProviderContext) {
|
||||
const esArchiver = getService('esArchiver');
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
const PageObjects = getPageObjects(['security', 'spaceSelector']);
|
||||
|
||||
describe('Enter Space', function() {
|
||||
|
@ -25,30 +24,7 @@ export default function enterSpaceFunctonalTests({
|
|||
await PageObjects.security.forceLogout();
|
||||
});
|
||||
|
||||
it('allows user to navigate to different spaces, respecting the configured default route', async () => {
|
||||
const spaceId = 'another-space';
|
||||
|
||||
await PageObjects.security.login(null, null, {
|
||||
expectSpaceSelector: true,
|
||||
});
|
||||
|
||||
await PageObjects.spaceSelector.clickSpaceCard(spaceId);
|
||||
|
||||
await PageObjects.spaceSelector.expectRoute(spaceId, '/app/kibana/#/dashboard');
|
||||
|
||||
await PageObjects.spaceSelector.openSpacesNav();
|
||||
|
||||
// change spaces
|
||||
|
||||
await PageObjects.spaceSelector.clickSpaceAvatar('default');
|
||||
|
||||
await PageObjects.spaceSelector.expectRoute('default', '/app/canvas');
|
||||
});
|
||||
|
||||
it('falls back to the default home page when the configured default route is malformed', async () => {
|
||||
await kibanaServer.uiSettings.replace({ defaultRoute: 'http://example.com/evil' });
|
||||
|
||||
// This test only works with the default space, as other spaces have an enforced relative url of `${serverBasePath}/s/space-id/${defaultRoute}`
|
||||
const spaceId = 'default';
|
||||
|
||||
await PageObjects.security.login(null, null, {
|
||||
|
@ -59,5 +35,25 @@ export default function enterSpaceFunctonalTests({
|
|||
|
||||
await PageObjects.spaceSelector.expectHomePage(spaceId);
|
||||
});
|
||||
|
||||
it('allows user to navigate to different spaces, respecting the configured default route', async () => {
|
||||
const spaceId = 'another-space';
|
||||
|
||||
await PageObjects.security.login(null, null, {
|
||||
expectSpaceSelector: true,
|
||||
});
|
||||
|
||||
await PageObjects.spaceSelector.clickSpaceCard(spaceId);
|
||||
|
||||
await PageObjects.spaceSelector.expectRoute(spaceId, '/app/canvas');
|
||||
|
||||
await PageObjects.spaceSelector.openSpacesNav();
|
||||
|
||||
// change spaces
|
||||
const newSpaceId = 'default';
|
||||
await PageObjects.spaceSelector.clickSpaceAvatar(newSpaceId);
|
||||
|
||||
await PageObjects.spaceSelector.expectHomePage(newSpaceId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
"config": {
|
||||
"buildNum": 8467,
|
||||
"dateFormat:tz": "UTC",
|
||||
"defaultRoute": "/app/canvas"
|
||||
"defaultRoute": "http://example.com/evil"
|
||||
},
|
||||
"type": "config"
|
||||
}
|
||||
|
@ -24,7 +24,7 @@
|
|||
"config": {
|
||||
"buildNum": 8467,
|
||||
"dateFormat:tz": "UTC",
|
||||
"defaultRoute": "/app/kibana/#dashboard"
|
||||
"defaultRoute": "/app/canvas"
|
||||
},
|
||||
"type": "config"
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue