NP licensing plugin improvements (#51818)
* add onPreResponse interceptor * use onPreResponse interceptor to add license sign * expose registerPreResponse to plugins * refresh for license update get the most fresh license * license plugin injects own header for signature: 'kbn-license-sig' * add integration tests for license type and license header * switch config to duration * don't run interceptor on anon paths. add tests * add functional tests for licensing plugin * regen docs * fix test in security due to updated mocks; * update snapshots accoring to new mock implementation * migrate license expired banner to NP * add readme for the licensing plugin * remove outdated import. licensing has separate functional tests * add tag for test to run on CI * regen docs * Update x-pack/plugins/licensing/README.md Co-Authored-By: Josh Dover <me@joshdover.com> * update tests
This commit is contained in:
parent
b66af16153
commit
dce893bb79
|
@ -1225,6 +1225,7 @@ In server code, `core` can be accessed from either `server.newPlatform` or `kbnS
|
|||
| `request.getBasePath()` | [`core.http.basePath.get`](/docs/development/core/server/kibana-plugin-server.httpservicesetup.basepath.md) | |
|
||||
| `server.plugins.elasticsearch.getCluster('data')` | [`core.elasticsearch.dataClient$`](/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.dataclient_.md) | Handlers will also include a pre-configured client |
|
||||
| `server.plugins.elasticsearch.getCluster('admin')` | [`core.elasticsearch.adminClient$`](/docs/development/core/server/kibana-plugin-server.elasticsearchservicesetup.adminclient_.md) | Handlers will also include a pre-configured client |
|
||||
| `xpackMainPlugin.info.feature(pluginID).registerLicenseCheckResultsGenerator` | [`x-pack licensing plugin`](/x-pack/plugins/licensing/README.md) | |
|
||||
|
||||
_See also: [Server's CoreSetup API Docs](/docs/development/core/server/kibana-plugin-server.coresetup.md)_
|
||||
|
||||
|
|
|
@ -21,10 +21,10 @@ import { HttpService } from './http_service';
|
|||
import { HttpSetup } from './types';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { BasePath } from './base_path_service';
|
||||
import { AnonymousPaths } from './anonymous_paths';
|
||||
|
||||
export type HttpSetupMock = jest.Mocked<HttpSetup> & {
|
||||
basePath: BasePath;
|
||||
anonymousPaths: jest.Mocked<HttpSetup['anonymousPaths']>;
|
||||
};
|
||||
|
||||
const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({
|
||||
|
@ -37,7 +37,10 @@ const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({
|
|||
delete: jest.fn(),
|
||||
options: jest.fn(),
|
||||
basePath: new BasePath(basePath),
|
||||
anonymousPaths: new AnonymousPaths(new BasePath(basePath)),
|
||||
anonymousPaths: {
|
||||
register: jest.fn(),
|
||||
isAnonymous: jest.fn(),
|
||||
},
|
||||
addLoadingCount: jest.fn(),
|
||||
getLoadingCount$: jest.fn().mockReturnValue(new BehaviorSubject(0)),
|
||||
stop: jest.fn(),
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
*/
|
||||
import { applicationServiceMock } from './application/application_service.mock';
|
||||
import { chromeServiceMock } from './chrome/chrome_service.mock';
|
||||
import { CoreContext, CoreSetup, CoreStart, PluginInitializerContext, NotificationsSetup } from '.';
|
||||
import { CoreContext, PluginInitializerContext } from '.';
|
||||
import { docLinksServiceMock } from './doc_links/doc_links_service.mock';
|
||||
import { fatalErrorsServiceMock } from './fatal_errors/fatal_errors_service.mock';
|
||||
import { httpServiceMock } from './http/http_service.mock';
|
||||
|
@ -42,7 +42,7 @@ export { overlayServiceMock } from './overlays/overlay_service.mock';
|
|||
export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock';
|
||||
|
||||
function createCoreSetupMock({ basePath = '' } = {}) {
|
||||
const mock: MockedKeys<CoreSetup> & { notifications: MockedKeys<NotificationsSetup> } = {
|
||||
const mock = {
|
||||
application: applicationServiceMock.createSetupContract(),
|
||||
context: contextServiceMock.createSetupContract(),
|
||||
fatalErrors: fatalErrorsServiceMock.createSetupContract(),
|
||||
|
@ -58,7 +58,7 @@ function createCoreSetupMock({ basePath = '' } = {}) {
|
|||
}
|
||||
|
||||
function createCoreStartMock({ basePath = '' } = {}) {
|
||||
const mock: MockedKeys<CoreStart> & { notifications: MockedKeys<NotificationsSetup> } = {
|
||||
const mock = {
|
||||
application: applicationServiceMock.createStartContract(),
|
||||
chrome: chromeServiceMock.createStartContract(),
|
||||
docLinks: docLinksServiceMock.createStartContract(),
|
||||
|
|
|
@ -25,6 +25,7 @@ import { OnPreAuthToolkit } from './lifecycle/on_pre_auth';
|
|||
import { AuthToolkit } from './lifecycle/auth';
|
||||
import { sessionStorageMock } from './cookie_session_storage.mocks';
|
||||
import { OnPostAuthToolkit } from './lifecycle/on_post_auth';
|
||||
import { OnPreResponseToolkit } from './lifecycle/on_pre_response';
|
||||
|
||||
export type HttpServiceSetupMock = jest.Mocked<InternalHttpServiceSetup> & {
|
||||
basePath: jest.Mocked<InternalHttpServiceSetup['basePath']>;
|
||||
|
@ -93,12 +94,17 @@ const createAuthToolkitMock = (): jest.Mocked<AuthToolkit> => ({
|
|||
authenticated: jest.fn(),
|
||||
});
|
||||
|
||||
const createOnPreResponseToolkitMock = (): jest.Mocked<OnPreResponseToolkit> => ({
|
||||
next: jest.fn(),
|
||||
});
|
||||
|
||||
export const httpServiceMock = {
|
||||
create: createHttpServiceMock,
|
||||
createBasePath: createBasePathMock,
|
||||
createSetupContract: createSetupContractMock,
|
||||
createOnPreAuthToolkit: createOnPreAuthToolkitMock,
|
||||
createOnPostAuthToolkit: createOnPostAuthToolkitMock,
|
||||
createOnPreResponseToolkit: createOnPreResponseToolkitMock,
|
||||
createAuthToolkit: createAuthToolkitMock,
|
||||
createRouter: mockRouter.create,
|
||||
};
|
||||
|
|
|
@ -964,7 +964,6 @@ describe('OnPreResponse', () => {
|
|||
headers: { 'x-kibana-header': 'value' },
|
||||
})
|
||||
);
|
||||
|
||||
registerOnPreResponse((req, res, t) =>
|
||||
t.next({
|
||||
headers: { 'x-kibana-header': 'value' },
|
||||
|
|
|
@ -305,14 +305,9 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA
|
|||
},
|
||||
"http": Object {
|
||||
"addLoadingCount": [MockFunction],
|
||||
"anonymousPaths": AnonymousPaths {
|
||||
"basePath": BasePath {
|
||||
"basePath": "",
|
||||
"get": [Function],
|
||||
"prepend": [Function],
|
||||
"remove": [Function],
|
||||
},
|
||||
"paths": Set {},
|
||||
"anonymousPaths": Object {
|
||||
"isAnonymous": [MockFunction],
|
||||
"register": [MockFunction],
|
||||
},
|
||||
"basePath": BasePath {
|
||||
"basePath": "",
|
||||
|
@ -936,14 +931,9 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA
|
|||
},
|
||||
"http": Object {
|
||||
"addLoadingCount": [MockFunction],
|
||||
"anonymousPaths": AnonymousPaths {
|
||||
"basePath": BasePath {
|
||||
"basePath": "",
|
||||
"get": [Function],
|
||||
"prepend": [Function],
|
||||
"remove": [Function],
|
||||
},
|
||||
"paths": Set {},
|
||||
"anonymousPaths": Object {
|
||||
"isAnonymous": [MockFunction],
|
||||
"register": [MockFunction],
|
||||
},
|
||||
"basePath": BasePath {
|
||||
"basePath": "",
|
||||
|
@ -1555,14 +1545,9 @@ exports[`QueryStringInput Should pass the query language to the language switche
|
|||
},
|
||||
"http": Object {
|
||||
"addLoadingCount": [MockFunction],
|
||||
"anonymousPaths": AnonymousPaths {
|
||||
"basePath": BasePath {
|
||||
"basePath": "",
|
||||
"get": [Function],
|
||||
"prepend": [Function],
|
||||
"remove": [Function],
|
||||
},
|
||||
"paths": Set {},
|
||||
"anonymousPaths": Object {
|
||||
"isAnonymous": [MockFunction],
|
||||
"register": [MockFunction],
|
||||
},
|
||||
"basePath": BasePath {
|
||||
"basePath": "",
|
||||
|
@ -2183,14 +2168,9 @@ exports[`QueryStringInput Should pass the query language to the language switche
|
|||
},
|
||||
"http": Object {
|
||||
"addLoadingCount": [MockFunction],
|
||||
"anonymousPaths": AnonymousPaths {
|
||||
"basePath": BasePath {
|
||||
"basePath": "",
|
||||
"get": [Function],
|
||||
"prepend": [Function],
|
||||
"remove": [Function],
|
||||
},
|
||||
"paths": Set {},
|
||||
"anonymousPaths": Object {
|
||||
"isAnonymous": [MockFunction],
|
||||
"register": [MockFunction],
|
||||
},
|
||||
"basePath": BasePath {
|
||||
"basePath": "",
|
||||
|
@ -2802,14 +2782,9 @@ exports[`QueryStringInput Should render the given query 1`] = `
|
|||
},
|
||||
"http": Object {
|
||||
"addLoadingCount": [MockFunction],
|
||||
"anonymousPaths": AnonymousPaths {
|
||||
"basePath": BasePath {
|
||||
"basePath": "",
|
||||
"get": [Function],
|
||||
"prepend": [Function],
|
||||
"remove": [Function],
|
||||
},
|
||||
"paths": Set {},
|
||||
"anonymousPaths": Object {
|
||||
"isAnonymous": [MockFunction],
|
||||
"register": [MockFunction],
|
||||
},
|
||||
"basePath": BasePath {
|
||||
"basePath": "",
|
||||
|
@ -3430,14 +3405,9 @@ exports[`QueryStringInput Should render the given query 1`] = `
|
|||
},
|
||||
"http": Object {
|
||||
"addLoadingCount": [MockFunction],
|
||||
"anonymousPaths": AnonymousPaths {
|
||||
"basePath": BasePath {
|
||||
"basePath": "",
|
||||
"get": [Function],
|
||||
"prepend": [Function],
|
||||
"remove": [Function],
|
||||
},
|
||||
"paths": Set {},
|
||||
"anonymousPaths": Object {
|
||||
"isAnonymous": [MockFunction],
|
||||
"register": [MockFunction],
|
||||
},
|
||||
"basePath": BasePath {
|
||||
"basePath": "",
|
||||
|
|
|
@ -47,9 +47,6 @@ export const xpackMain = (kibana) => {
|
|||
},
|
||||
|
||||
uiExports: {
|
||||
hacks: [
|
||||
'plugins/xpack_main/hacks/check_xpack_info_change',
|
||||
],
|
||||
replaceInjectedVars,
|
||||
injectDefaultVars(server) {
|
||||
const config = server.config();
|
||||
|
|
|
@ -1,102 +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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import ngMock from 'ng_mock';
|
||||
import sinon from 'sinon';
|
||||
import { banners } from 'ui/notify';
|
||||
|
||||
const XPACK_INFO_SIG_KEY = 'xpackMain.infoSignature';
|
||||
const XPACK_INFO_KEY = 'xpackMain.info';
|
||||
|
||||
describe('CheckXPackInfoChange Factory', () => {
|
||||
const sandbox = sinon.createSandbox();
|
||||
|
||||
let mockSessionStorage;
|
||||
beforeEach(ngMock.module('kibana', ($provide) => {
|
||||
mockSessionStorage = sinon.stub({
|
||||
setItem() {},
|
||||
getItem() {},
|
||||
removeItem() {}
|
||||
});
|
||||
|
||||
mockSessionStorage.getItem.withArgs(XPACK_INFO_SIG_KEY).returns('foo');
|
||||
|
||||
$provide.service('$window', () => ({
|
||||
sessionStorage: mockSessionStorage,
|
||||
location: { pathname: '' }
|
||||
}));
|
||||
}));
|
||||
|
||||
let $http;
|
||||
let $httpBackend;
|
||||
let $timeout;
|
||||
beforeEach(ngMock.inject(($injector) => {
|
||||
$http = $injector.get('$http');
|
||||
$httpBackend = $injector.get('$httpBackend');
|
||||
$timeout = $injector.get('$timeout');
|
||||
|
||||
// We set 'kbn-system-api' to not trigger other unrelated toast notifications
|
||||
// like the one related to the session expiration.
|
||||
$http.defaults.headers.common['kbn-system-api'] = 'x';
|
||||
|
||||
sandbox.stub(banners, 'add');
|
||||
}));
|
||||
|
||||
afterEach(function () {
|
||||
$httpBackend.verifyNoOutstandingRequest();
|
||||
$timeout.verifyNoPendingTasks();
|
||||
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
it('does not show "license expired" banner if license is not expired.', () => {
|
||||
const license = { license: { isActive: true, type: 'x-license' } };
|
||||
mockSessionStorage.getItem.withArgs(XPACK_INFO_KEY).returns(JSON.stringify(license));
|
||||
|
||||
$httpBackend
|
||||
.when('POST', '/api/test')
|
||||
.respond('ok', { 'kbn-xpack-sig': 'foo' });
|
||||
|
||||
$httpBackend
|
||||
.when('GET', '/api/xpack/v1/info')
|
||||
.respond(license, { 'kbn-xpack-sig': 'foo' });
|
||||
|
||||
$http.post('/api/test');
|
||||
$httpBackend.flush();
|
||||
$timeout.flush();
|
||||
|
||||
sinon.assert.notCalled(banners.add);
|
||||
});
|
||||
|
||||
it('shows "license expired" banner if license is expired only once.', async () => {
|
||||
const license = { license: { isActive: false, type: 'diamond' } };
|
||||
mockSessionStorage.getItem.withArgs(XPACK_INFO_KEY).returns(JSON.stringify(license));
|
||||
|
||||
$httpBackend
|
||||
.when('POST', '/api/test')
|
||||
.respond('ok', { 'kbn-xpack-sig': 'bar' });
|
||||
|
||||
$httpBackend
|
||||
.when('GET', '/api/xpack/v1/info')
|
||||
.respond(license, { 'kbn-xpack-sig': 'bar' });
|
||||
|
||||
$http.post('/api/test');
|
||||
$httpBackend.flush();
|
||||
$timeout.flush();
|
||||
|
||||
sinon.assert.calledOnce(banners.add);
|
||||
|
||||
// If license didn't change banner shouldn't be displayed.
|
||||
banners.add.resetHistory();
|
||||
mockSessionStorage.getItem.withArgs(XPACK_INFO_SIG_KEY).returns('bar');
|
||||
|
||||
$http.post('/api/test');
|
||||
$httpBackend.flush();
|
||||
$timeout.flush();
|
||||
|
||||
sinon.assert.notCalled(banners.add);
|
||||
});
|
||||
});
|
|
@ -1,107 +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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { identity } from 'lodash';
|
||||
import { EuiCallOut } from '@elastic/eui';
|
||||
import { uiModules } from 'ui/modules';
|
||||
import chrome from 'ui/chrome';
|
||||
import { banners } from 'ui/notify';
|
||||
import { DebounceProvider } from 'ui/directives/debounce';
|
||||
import { Path } from 'plugins/xpack_main/services/path';
|
||||
import { xpackInfo } from 'plugins/xpack_main/services/xpack_info';
|
||||
import { xpackInfoSignature } from 'plugins/xpack_main/services/xpack_info_signature';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
const module = uiModules.get('xpack_main', []);
|
||||
|
||||
module.factory('checkXPackInfoChange', ($q, Private, $injector) => {
|
||||
const debounce = Private(DebounceProvider);
|
||||
const isUnauthenticated = Path.isUnauthenticated();
|
||||
let isLicenseExpirationBannerShown = false;
|
||||
|
||||
const notifyIfLicenseIsExpired = debounce(() => {
|
||||
const license = xpackInfo.get('license');
|
||||
if (license.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadLicensePath = `${chrome.getBasePath()}/app/kibana#/management/elasticsearch/license_management/upload_license`;
|
||||
|
||||
if (!isLicenseExpirationBannerShown) {
|
||||
isLicenseExpirationBannerShown = true;
|
||||
banners.add({
|
||||
component: (
|
||||
<EuiCallOut
|
||||
iconType="help"
|
||||
color="warning"
|
||||
title={<FormattedMessage
|
||||
id="xpack.main.welcomeBanner.licenseIsExpiredTitle"
|
||||
defaultMessage="Your {licenseType} license is expired"
|
||||
values={{ licenseType: license.type }}
|
||||
/>}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.main.welcomeBanner.licenseIsExpiredDescription"
|
||||
defaultMessage="Contact your administrator or {updateYourLicenseLinkText} directly."
|
||||
values={{
|
||||
updateYourLicenseLinkText: (
|
||||
<a href={uploadLicensePath}>
|
||||
<FormattedMessage
|
||||
id="xpack.main.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText"
|
||||
defaultMessage="update your license"
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</EuiCallOut>
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Intercept each network response to look for the kbn-xpack-sig header.
|
||||
* When that header is detected, compare its value with the value cached
|
||||
* in the browser storage. When the value is new, call `xpackInfo.refresh()`
|
||||
* so that it will pull down the latest x-pack info
|
||||
*
|
||||
* @param {object} response - the angular $http response object
|
||||
* @param {function} handleResponse - callback, expects to receive the response
|
||||
* @return
|
||||
*/
|
||||
function interceptor(response, handleResponse) {
|
||||
if (isUnauthenticated) {
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
const currentSignature = response.headers('kbn-xpack-sig');
|
||||
const cachedSignature = xpackInfoSignature.get();
|
||||
|
||||
if (currentSignature && cachedSignature !== currentSignature) {
|
||||
// Signature from the server differ from the signature of our
|
||||
// cached info, so we need to refresh it.
|
||||
// Intentionally swallowing this error
|
||||
// because nothing catches it and it's an ugly console error.
|
||||
xpackInfo.refresh($injector).then(
|
||||
() => notifyIfLicenseIsExpired(),
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
return {
|
||||
response: (response) => interceptor(response, identity),
|
||||
responseError: (response) => interceptor(response, $q.reject)
|
||||
};
|
||||
});
|
||||
|
||||
module.config(($httpProvider) => {
|
||||
$httpProvider.interceptors.push('checkXPackInfoChange');
|
||||
});
|
94
x-pack/plugins/licensing/README.md
Normal file
94
x-pack/plugins/licensing/README.md
Normal file
|
@ -0,0 +1,94 @@
|
|||
# Licensing plugin
|
||||
|
||||
Retrieves license data from Elasticsearch and becomes a source of license data for all Kibana plugins on server-side and client-side.
|
||||
|
||||
## API:
|
||||
### Server-side
|
||||
The licensing plugin retrieves license data from **Elasticsearch** at regular configurable intervals.
|
||||
- `license$: Observable<ILicense>` Provides a steam of license data [ILicense](./common/types.ts). Plugin emits new value whenever it detects changes in license info. If the plugin cannot retrieve a license from **Elasticsearch**, it will emit `an empty license` object.
|
||||
- `refresh: () => Promise<ILicense>` allows a plugin to enforce license retrieval.
|
||||
|
||||
### Client-side
|
||||
The licensing plugin retrieves license data from **licensing Kibana plugin** and does not communicate with Elasticsearch directly.
|
||||
- `license$: Observable<ILicense>` Provides a steam of license data [ILicense](./common/types.ts). Plugin emits new value whenever it detects changes in license info. If the plugin cannot retrieve a license from **Kibana**, it will emit `an empty license` object.
|
||||
- `refresh: () => Promise<ILicense>` allows a plugin to enforce license retrieval.
|
||||
|
||||
## Migration path
|
||||
The new platform licensing plugin became stateless now. It means that instead of storing all your data from `checkLicense` within the plugin, you should react on license data change on both the client and server sides.
|
||||
|
||||
### Before
|
||||
```ts
|
||||
// my_plugin/server/plugin.ts
|
||||
function checkLicense(xpackLicenseInfo: XPackInfo){
|
||||
if (!xpackLicenseInfo || !xpackLicenseInfo.isAvailable()) {
|
||||
return {
|
||||
isAvailable: false,
|
||||
showLinks: true,
|
||||
}
|
||||
}
|
||||
if (!xpackLicenseInfo.feature('name').isEnabled()) {
|
||||
return {
|
||||
isAvailable: false,
|
||||
showLinks: false,
|
||||
}
|
||||
}
|
||||
const hasRequiredLicense = xPackInfo.license.isOneOf([
|
||||
'gold',
|
||||
'platinum',
|
||||
'trial',
|
||||
]);
|
||||
return {
|
||||
isAvailable: hasRequiredLicense,
|
||||
showLinks: hasRequiredLicense,
|
||||
}
|
||||
}
|
||||
xpackMainPlugin.info.feature(pluginId).registerLicenseCheckResultsGenerator(checkLicense);
|
||||
|
||||
// my_plugin/client/plugin.ts
|
||||
chrome.navLinks.update('myPlugin', {
|
||||
hidden: !xpackInfo.get('features.myPlugin.showLinks', false)
|
||||
});
|
||||
```
|
||||
|
||||
### After
|
||||
```ts
|
||||
// kibana.json
|
||||
"requiredPlugins": ["licensing"],
|
||||
|
||||
// my_plugin/server/plugin.ts
|
||||
import { LicensingPluginSetup, LICENSE_CHECK_STATE } from '../licensing'
|
||||
|
||||
interface SetupDeps {
|
||||
licensing: LicensingPluginSetup;
|
||||
}
|
||||
|
||||
class MyPlugin {
|
||||
setup(core: CoreSetup, deps: SetupDeps) {
|
||||
deps.licensing.license$.subscribe(license => {
|
||||
const { state, message } = license.check('myPlugin', 'gold')
|
||||
const hasRequiredLicense = state === LICENSE_CHECK_STATE.Valid;
|
||||
if (hasRequiredLicense && license.getFeature('name').isAvailable) {
|
||||
// enable some server side logic
|
||||
} else {
|
||||
log(message);
|
||||
// disable some server side logic
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// my_plugin/client/plugin.ts
|
||||
class MyPlugin {
|
||||
setup(core: CoreSetup, deps: SetupDeps) {
|
||||
deps.licensing.license$.subscribe(license => {
|
||||
const { state, message } = license.check('myPlugin', 'gold')
|
||||
const hasRequiredLicense = state === LICENSE_CHECK_STATE.Valid;
|
||||
const showLinks = hasRequiredLicense && license.getFeature('name').isAvailable;
|
||||
|
||||
chrome.navLinks.update('myPlugin', {
|
||||
hidden: !showLinks
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
|
@ -12,7 +12,7 @@ import { createLicenseUpdate } from './license_update';
|
|||
import { licenseMock } from './license.mock';
|
||||
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const stop$ = new Subject();
|
||||
describe('licensing update', () => {
|
||||
it('loads updates when triggered', async () => {
|
||||
const types: LicenseType[] = ['basic', 'gold'];
|
||||
|
@ -24,16 +24,16 @@ describe('licensing update', () => {
|
|||
Promise.resolve(licenseMock.create({ license: { type: types.shift() } }))
|
||||
);
|
||||
|
||||
const { update$ } = createLicenseUpdate(trigger$, fetcher);
|
||||
const { license$ } = createLicenseUpdate(trigger$, stop$, fetcher);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(0);
|
||||
|
||||
trigger$.next();
|
||||
const first = await update$.pipe(take(1)).toPromise();
|
||||
const first = await license$.pipe(take(1)).toPromise();
|
||||
expect(first.type).toBe('basic');
|
||||
|
||||
trigger$.next();
|
||||
const [, second] = await update$.pipe(take(2), toArray()).toPromise();
|
||||
const [, second] = await license$.pipe(take(2), toArray()).toPromise();
|
||||
expect(second.type).toBe('gold');
|
||||
});
|
||||
|
||||
|
@ -43,9 +43,9 @@ describe('licensing update', () => {
|
|||
const trigger$ = new Subject();
|
||||
|
||||
const fetcher = jest.fn().mockResolvedValue(fetchedLicense);
|
||||
const { update$ } = createLicenseUpdate(trigger$, fetcher, initialLicense);
|
||||
const { license$ } = createLicenseUpdate(trigger$, stop$, fetcher, initialLicense);
|
||||
trigger$.next();
|
||||
const [first, second] = await update$.pipe(take(2), toArray()).toPromise();
|
||||
const [first, second] = await license$.pipe(take(2), toArray()).toPromise();
|
||||
|
||||
expect(first.type).toBe('platinum');
|
||||
expect(second.type).toBe('gold');
|
||||
|
@ -64,17 +64,17 @@ describe('licensing update', () => {
|
|||
)
|
||||
);
|
||||
|
||||
const { update$ } = createLicenseUpdate(trigger$, fetcher);
|
||||
const { license$ } = createLicenseUpdate(trigger$, stop$, fetcher);
|
||||
trigger$.next();
|
||||
|
||||
const [first] = await update$.pipe(take(1), toArray()).toPromise();
|
||||
const [first] = await license$.pipe(take(1), toArray()).toPromise();
|
||||
|
||||
expect(first.type).toBe('basic');
|
||||
|
||||
trigger$.next();
|
||||
trigger$.next();
|
||||
|
||||
const [, second] = await update$.pipe(take(2), toArray()).toPromise();
|
||||
const [, second] = await license$.pipe(take(2), toArray()).toPromise();
|
||||
|
||||
expect(second.type).toBe('gold');
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
|
@ -85,11 +85,11 @@ describe('licensing update', () => {
|
|||
|
||||
const fetcher = jest.fn().mockResolvedValue(licenseMock.create());
|
||||
|
||||
const { update$ } = createLicenseUpdate(trigger$, fetcher);
|
||||
const { license$ } = createLicenseUpdate(trigger$, stop$, fetcher);
|
||||
|
||||
update$.subscribe(() => {});
|
||||
update$.subscribe(() => {});
|
||||
update$.subscribe(() => {});
|
||||
license$.subscribe(() => {});
|
||||
license$.subscribe(() => {});
|
||||
license$.subscribe(() => {});
|
||||
trigger$.next();
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
|
@ -110,9 +110,9 @@ describe('licensing update', () => {
|
|||
})
|
||||
);
|
||||
const trigger$ = new Subject();
|
||||
const { update$ } = createLicenseUpdate(trigger$, fetcher);
|
||||
const { license$ } = createLicenseUpdate(trigger$, stop$, fetcher);
|
||||
const values: ILicense[] = [];
|
||||
update$.subscribe(license => values.push(license));
|
||||
license$.subscribe(license => values.push(license));
|
||||
|
||||
trigger$.next();
|
||||
trigger$.next();
|
||||
|
@ -124,29 +124,58 @@ describe('licensing update', () => {
|
|||
await expect(values[0].type).toBe('gold');
|
||||
});
|
||||
|
||||
it('completes update$ stream when trigger is completed', () => {
|
||||
it('completes license$ stream when stop$ is triggered', () => {
|
||||
const trigger$ = new Subject();
|
||||
const fetcher = jest.fn().mockResolvedValue(licenseMock.create());
|
||||
|
||||
const { update$ } = createLicenseUpdate(trigger$, fetcher);
|
||||
const { license$ } = createLicenseUpdate(trigger$, stop$, fetcher);
|
||||
let completed = false;
|
||||
update$.subscribe({ complete: () => (completed = true) });
|
||||
license$.subscribe({ complete: () => (completed = true) });
|
||||
|
||||
trigger$.complete();
|
||||
stop$.next();
|
||||
expect(completed).toBe(true);
|
||||
});
|
||||
|
||||
it('stops fetching when fetch subscription unsubscribed', () => {
|
||||
it('stops fetching when stop$ is triggered', () => {
|
||||
const trigger$ = new Subject();
|
||||
const fetcher = jest.fn().mockResolvedValue(licenseMock.create());
|
||||
|
||||
const { update$, fetchSubscription } = createLicenseUpdate(trigger$, fetcher);
|
||||
const { license$ } = createLicenseUpdate(trigger$, stop$, fetcher);
|
||||
const values: ILicense[] = [];
|
||||
update$.subscribe(license => values.push(license));
|
||||
license$.subscribe(license => values.push(license));
|
||||
|
||||
fetchSubscription.unsubscribe();
|
||||
stop$.next();
|
||||
trigger$.next();
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('refreshManually guarantees license fetching', async () => {
|
||||
const trigger$ = new Subject();
|
||||
const firstLicense = licenseMock.create({ license: { uid: 'first', type: 'basic' } });
|
||||
const secondLicense = licenseMock.create({ license: { uid: 'second', type: 'gold' } });
|
||||
|
||||
const fetcher = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => {
|
||||
await delay(100);
|
||||
return firstLicense;
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
await delay(100);
|
||||
return secondLicense;
|
||||
});
|
||||
|
||||
const { license$, refreshManually } = createLicenseUpdate(trigger$, stop$, fetcher);
|
||||
let fromObservable;
|
||||
license$.subscribe(license => (fromObservable = license));
|
||||
|
||||
const licenseResult = await refreshManually();
|
||||
expect(licenseResult.uid).toBe('first');
|
||||
expect(licenseResult).toBe(fromObservable);
|
||||
|
||||
const secondResult = await refreshManually();
|
||||
expect(secondResult.uid).toBe('second');
|
||||
expect(secondResult).toBe(fromObservable);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -3,36 +3,45 @@
|
|||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { ConnectableObservable, Observable, from, merge } from 'rxjs';
|
||||
import { ConnectableObservable, Observable, Subject, from, merge } from 'rxjs';
|
||||
|
||||
import { filter, map, pairwise, switchMap, publishReplay } from 'rxjs/operators';
|
||||
import { filter, map, pairwise, switchMap, publishReplay, takeUntil } from 'rxjs/operators';
|
||||
import { hasLicenseInfoChanged } from './has_license_info_changed';
|
||||
import { ILicense } from './types';
|
||||
|
||||
export function createLicenseUpdate(
|
||||
trigger$: Observable<unknown>,
|
||||
stop$: Observable<unknown>,
|
||||
fetcher: () => Promise<ILicense>,
|
||||
initialValues?: ILicense
|
||||
) {
|
||||
const fetched$ = trigger$.pipe(
|
||||
switchMap(fetcher),
|
||||
const triggerRefresh$ = trigger$.pipe(switchMap(fetcher));
|
||||
const manuallyFetched$ = new Subject<ILicense>();
|
||||
|
||||
const fetched$ = merge(triggerRefresh$, manuallyFetched$).pipe(
|
||||
takeUntil(stop$),
|
||||
publishReplay(1)
|
||||
// have to cast manually as pipe operator cannot return ConnectableObservable
|
||||
// https://github.com/ReactiveX/rxjs/issues/2972
|
||||
) as ConnectableObservable<ILicense>;
|
||||
|
||||
const fetchSubscription = fetched$.connect();
|
||||
stop$.subscribe({ complete: () => fetchSubscription.unsubscribe() });
|
||||
|
||||
const initialValues$ = initialValues ? from([undefined, initialValues]) : from([undefined]);
|
||||
|
||||
const update$: Observable<ILicense> = merge(initialValues$, fetched$).pipe(
|
||||
const license$: Observable<ILicense> = merge(initialValues$, fetched$).pipe(
|
||||
pairwise(),
|
||||
filter(([previous, next]) => hasLicenseInfoChanged(previous, next!)),
|
||||
map(([, next]) => next!)
|
||||
);
|
||||
|
||||
return {
|
||||
update$,
|
||||
fetchSubscription,
|
||||
license$,
|
||||
async refreshManually() {
|
||||
const license = await fetcher();
|
||||
manuallyFetched$.next(license);
|
||||
return license;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -183,5 +183,5 @@ export interface LicensingPluginSetup {
|
|||
/**
|
||||
* Triggers licensing information re-fetch.
|
||||
*/
|
||||
refresh(): void;
|
||||
refresh(): Promise<ILicense>;
|
||||
}
|
||||
|
|
48
x-pack/plugins/licensing/public/expired_banner.tsx
Normal file
48
x-pack/plugins/licensing/public/expired_banner.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { EuiCallOut } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
|
||||
import { toMountPoint } from '../../../../src/plugins/kibana_react/public';
|
||||
|
||||
interface Props {
|
||||
type: string;
|
||||
uploadUrl: string;
|
||||
}
|
||||
|
||||
const ExpiredBanner: React.FunctionComponent<Props> = props => (
|
||||
<EuiCallOut
|
||||
iconType="help"
|
||||
color="warning"
|
||||
data-test-subj="licenseExpiredBanner"
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.licensing.welcomeBanner.licenseIsExpiredTitle"
|
||||
defaultMessage="Your {licenseType} license is expired"
|
||||
values={{ licenseType: props.type }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.licensing.welcomeBanner.licenseIsExpiredDescription"
|
||||
defaultMessage="Contact your administrator or {updateYourLicenseLinkText} directly."
|
||||
values={{
|
||||
updateYourLicenseLinkText: (
|
||||
<a href={props.uploadUrl}>
|
||||
<FormattedMessage
|
||||
id="xpack.licensing.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText"
|
||||
defaultMessage="update your license"
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</EuiCallOut>
|
||||
);
|
||||
|
||||
export const mountExpiredBanner = (props: Props) =>
|
||||
toMountPoint(<ExpiredBanner type={props.type!} uploadUrl={props.uploadUrl} />);
|
|
@ -4,10 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function licensingIntegrationTests({ loadTestFile }: FtrProviderContext) {
|
||||
describe('Licensing', () => {
|
||||
loadTestFile(require.resolve('./info'));
|
||||
});
|
||||
}
|
||||
export const mountExpiredBannerMock = jest.fn();
|
||||
jest.doMock('./expired_banner', () => ({
|
||||
mountExpiredBanner: mountExpiredBannerMock,
|
||||
}));
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import { take } from 'rxjs/operators';
|
||||
import { mountExpiredBannerMock } from './plugin.test.mocks';
|
||||
|
||||
import { LicenseType } from '../common/types';
|
||||
import { LicensingPlugin, licensingSessionStorageKey } from './plugin';
|
||||
|
@ -14,10 +15,13 @@ import { licenseMock } from '../common/license.mock';
|
|||
import { coreMock } from '../../../../src/core/public/mocks';
|
||||
import { HttpInterceptor } from 'src/core/public';
|
||||
|
||||
const delay = (ms: number) => new Promise(res => setTimeout(res, ms));
|
||||
|
||||
describe('licensing plugin', () => {
|
||||
let plugin: LicensingPlugin;
|
||||
|
||||
afterEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
await plugin.stop();
|
||||
});
|
||||
|
||||
|
@ -28,15 +32,30 @@ describe('licensing plugin', () => {
|
|||
plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage);
|
||||
|
||||
const coreSetup = coreMock.createSetup();
|
||||
const fetchedLicense = licenseMock.create({ license: { uid: 'fetched' } });
|
||||
coreSetup.http.get.mockResolvedValue(fetchedLicense);
|
||||
const firstLicense = licenseMock.create({ license: { uid: 'first', type: 'basic' } });
|
||||
const secondLicense = licenseMock.create({ license: { uid: 'second', type: 'gold' } });
|
||||
coreSetup.http.get
|
||||
.mockImplementationOnce(async () => {
|
||||
await delay(100);
|
||||
return firstLicense;
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
await delay(100);
|
||||
return secondLicense;
|
||||
});
|
||||
|
||||
const { license$, refresh } = await plugin.setup(coreSetup);
|
||||
|
||||
refresh();
|
||||
const license = await license$.pipe(take(1)).toPromise();
|
||||
let fromObservable;
|
||||
license$.subscribe(license => (fromObservable = license));
|
||||
|
||||
expect(license.uid).toBe('fetched');
|
||||
const licenseResult = await refresh();
|
||||
expect(licenseResult.uid).toBe('first');
|
||||
expect(licenseResult).toBe(fromObservable);
|
||||
|
||||
const secondResult = await refresh();
|
||||
expect(secondResult.uid).toBe('second');
|
||||
expect(secondResult).toBe(fromObservable);
|
||||
});
|
||||
|
||||
it('data re-fetch call marked as a system api', async () => {
|
||||
|
@ -49,7 +68,7 @@ describe('licensing plugin', () => {
|
|||
|
||||
const { refresh } = await plugin.setup(coreSetup);
|
||||
|
||||
refresh();
|
||||
await refresh();
|
||||
|
||||
expect(coreSetup.http.get.mock.calls[0][1]).toMatchObject({
|
||||
headers: {
|
||||
|
@ -119,7 +138,7 @@ describe('licensing plugin', () => {
|
|||
|
||||
const { license$, refresh } = await plugin.setup(coreSetup);
|
||||
|
||||
refresh();
|
||||
await refresh();
|
||||
const license = await license$.pipe(take(1)).toPromise();
|
||||
|
||||
expect(license.uid).toBe('fresh');
|
||||
|
@ -143,7 +162,7 @@ describe('licensing plugin', () => {
|
|||
coreSetup.http.get.mockRejectedValue(new Error('reason'));
|
||||
|
||||
const { license$, refresh } = await plugin.setup(coreSetup);
|
||||
refresh();
|
||||
await refresh();
|
||||
|
||||
const license = await license$.pipe(take(1)).toPromise();
|
||||
|
||||
|
@ -161,7 +180,7 @@ describe('licensing plugin', () => {
|
|||
const { license$, refresh } = await plugin.setup(coreSetup);
|
||||
expect(sessionStorage.removeItem).toHaveBeenCalledTimes(0);
|
||||
|
||||
refresh();
|
||||
await refresh();
|
||||
await license$.pipe(take(1)).toPromise();
|
||||
|
||||
expect(sessionStorage.removeItem).toHaveBeenCalledTimes(1);
|
||||
|
@ -169,6 +188,7 @@ describe('licensing plugin', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('interceptor', () => {
|
||||
it('register http interceptor checking signature header', async () => {
|
||||
const sessionStorage = coreMock.createStorage();
|
||||
|
@ -201,7 +221,7 @@ describe('licensing plugin', () => {
|
|||
response: {
|
||||
headers: {
|
||||
get(name: string) {
|
||||
if (name === 'kbn-xpack-sig') {
|
||||
if (name === 'kbn-license-sig') {
|
||||
return 'signature-1';
|
||||
}
|
||||
throw new Error('unexpected header');
|
||||
|
@ -226,6 +246,40 @@ describe('licensing plugin', () => {
|
|||
expect(coreSetup.http.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('http interceptor does not trigger license re-fetch for anonymous pages', async () => {
|
||||
const sessionStorage = coreMock.createStorage();
|
||||
plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage);
|
||||
|
||||
const coreSetup = coreMock.createSetup();
|
||||
coreSetup.http.anonymousPaths.isAnonymous.mockReturnValue(true);
|
||||
|
||||
let registeredInterceptor: HttpInterceptor;
|
||||
coreSetup.http.intercept.mockImplementation((interceptor: HttpInterceptor) => {
|
||||
registeredInterceptor = interceptor;
|
||||
return () => undefined;
|
||||
});
|
||||
|
||||
await plugin.setup(coreSetup);
|
||||
const httpResponse = {
|
||||
response: {
|
||||
headers: {
|
||||
get(name: string) {
|
||||
if (name === 'kbn-license-sig') {
|
||||
return 'signature-1';
|
||||
}
|
||||
throw new Error('unexpected header');
|
||||
},
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: 'http://10.10.10.10:5601/api/hello',
|
||||
},
|
||||
};
|
||||
await registeredInterceptor!.response!(httpResponse as any, null as any);
|
||||
|
||||
expect(coreSetup.http.get).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('http interceptor does not trigger re-fetch if requested x-pack/info endpoint', async () => {
|
||||
const sessionStorage = coreMock.createStorage();
|
||||
plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage);
|
||||
|
@ -249,7 +303,7 @@ describe('licensing plugin', () => {
|
|||
response: {
|
||||
headers: {
|
||||
get(name: string) {
|
||||
if (name === 'kbn-xpack-sig') {
|
||||
if (name === 'kbn-license-sig') {
|
||||
return 'signature-1';
|
||||
}
|
||||
throw new Error('unexpected header');
|
||||
|
@ -269,6 +323,59 @@ describe('licensing plugin', () => {
|
|||
expect(updated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expired banner', () => {
|
||||
it('does not show "license expired" banner if license is not expired.', async () => {
|
||||
const sessionStorage = coreMock.createStorage();
|
||||
plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage);
|
||||
|
||||
const coreSetup = coreMock.createSetup();
|
||||
coreSetup.http.get.mockResolvedValueOnce(
|
||||
licenseMock.create({ license: { status: 'active', type: 'gold' } })
|
||||
);
|
||||
|
||||
const { refresh } = await plugin.setup(coreSetup);
|
||||
|
||||
const coreStart = coreMock.createStart();
|
||||
await plugin.start(coreStart);
|
||||
|
||||
await refresh();
|
||||
expect(coreStart.overlays.banners.add).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('shows "license expired" banner if license is expired only once.', async () => {
|
||||
const sessionStorage = coreMock.createStorage();
|
||||
plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage);
|
||||
|
||||
const coreSetup = coreMock.createSetup();
|
||||
const activeLicense = licenseMock.create({ license: { status: 'active', type: 'gold' } });
|
||||
const expiredLicense = licenseMock.create({ license: { status: 'expired', type: 'gold' } });
|
||||
coreSetup.http.get
|
||||
.mockResolvedValueOnce(activeLicense)
|
||||
.mockResolvedValueOnce(expiredLicense)
|
||||
.mockResolvedValueOnce(activeLicense)
|
||||
.mockResolvedValueOnce(expiredLicense);
|
||||
|
||||
const { refresh } = await plugin.setup(coreSetup);
|
||||
|
||||
const coreStart = coreMock.createStart();
|
||||
await plugin.start(coreStart);
|
||||
|
||||
await refresh();
|
||||
expect(coreStart.overlays.banners.add).toHaveBeenCalledTimes(0);
|
||||
await refresh();
|
||||
expect(coreStart.overlays.banners.add).toHaveBeenCalledTimes(1);
|
||||
await refresh();
|
||||
expect(coreStart.overlays.banners.add).toHaveBeenCalledTimes(1);
|
||||
await refresh();
|
||||
expect(coreStart.overlays.banners.add).toHaveBeenCalledTimes(1);
|
||||
expect(mountExpiredBannerMock).toHaveBeenCalledWith({
|
||||
type: 'gold',
|
||||
uploadUrl: '/app/kibana#/management/elasticsearch/license_management/upload_license',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#stop', () => {
|
||||
it('stops polling', async () => {
|
||||
const sessionStorage = coreMock.createStorage();
|
||||
|
@ -283,19 +390,6 @@ describe('licensing plugin', () => {
|
|||
expect(completed).toBe(true);
|
||||
});
|
||||
|
||||
it('refresh does not trigger data re-fetch', async () => {
|
||||
const sessionStorage = coreMock.createStorage();
|
||||
plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage);
|
||||
const coreSetup = coreMock.createSetup();
|
||||
const { refresh } = await plugin.setup(coreSetup);
|
||||
|
||||
await plugin.stop();
|
||||
|
||||
refresh();
|
||||
|
||||
expect(coreSetup.http.get).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('removes http interceptor', async () => {
|
||||
const sessionStorage = coreMock.createStorage();
|
||||
plugin = new LicensingPlugin(coreMock.createPluginInitializerContext(), sessionStorage);
|
||||
|
|
|
@ -3,15 +3,14 @@
|
|||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
|
||||
import { Subject, Subscription, merge } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public';
|
||||
import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public';
|
||||
|
||||
import { ILicense, LicensingPluginSetup } from '../common/types';
|
||||
import { createLicenseUpdate } from '../common/license_update';
|
||||
import { License } from '../common/license';
|
||||
import { mountExpiredBanner } from './expired_banner';
|
||||
|
||||
export const licensingSessionStorageKey = 'xpack.licensing';
|
||||
|
||||
|
@ -30,10 +29,11 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
|
|||
* A function to execute once the plugin's HTTP interceptor needs to stop listening.
|
||||
*/
|
||||
private removeInterceptor?: () => void;
|
||||
private licenseFetchSubscription?: Subscription;
|
||||
private storageSubscription?: Subscription;
|
||||
private internalSubscription?: Subscription;
|
||||
private isLicenseExpirationBannerShown? = false;
|
||||
|
||||
private readonly infoEndpoint = '/api/licensing/info';
|
||||
private coreStart?: CoreStart;
|
||||
private prevSignature?: string;
|
||||
|
||||
constructor(
|
||||
|
@ -65,19 +65,16 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
|
|||
}
|
||||
|
||||
public setup(core: CoreSetup) {
|
||||
const manualRefresh$ = new Subject();
|
||||
const signatureUpdated$ = new Subject();
|
||||
const refresh$ = merge(signatureUpdated$, manualRefresh$).pipe(takeUntil(this.stop$));
|
||||
|
||||
const savedLicense = this.getSaved();
|
||||
const { update$, fetchSubscription } = createLicenseUpdate(
|
||||
refresh$,
|
||||
const { license$, refreshManually } = createLicenseUpdate(
|
||||
signatureUpdated$,
|
||||
this.stop$,
|
||||
() => this.fetchLicense(core),
|
||||
savedLicense
|
||||
this.getSaved()
|
||||
);
|
||||
this.licenseFetchSubscription = fetchSubscription;
|
||||
|
||||
this.storageSubscription = update$.subscribe(license => {
|
||||
this.internalSubscription = license$.subscribe(license => {
|
||||
if (license.isAvailable) {
|
||||
this.prevSignature = license.signature;
|
||||
this.save(license);
|
||||
|
@ -86,12 +83,19 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
|
|||
// Prevent reusing stale license if the fetch operation fails
|
||||
this.removeSaved();
|
||||
}
|
||||
|
||||
if (license.status === 'expired' && !this.isLicenseExpirationBannerShown && this.coreStart) {
|
||||
this.isLicenseExpirationBannerShown = true;
|
||||
this.showExpiredBanner(license);
|
||||
}
|
||||
});
|
||||
|
||||
this.removeInterceptor = core.http.intercept({
|
||||
response: async httpResponse => {
|
||||
// we don't track license as anon users do not have one.
|
||||
if (core.http.anonymousPaths.isAnonymous(window.location.pathname)) return httpResponse;
|
||||
if (httpResponse.response) {
|
||||
const signatureHeader = httpResponse.response.headers.get('kbn-xpack-sig');
|
||||
const signatureHeader = httpResponse.response.headers.get('kbn-license-sig');
|
||||
if (this.prevSignature !== signatureHeader) {
|
||||
if (!httpResponse.request!.url.includes(this.infoEndpoint)) {
|
||||
signatureUpdated$.next();
|
||||
|
@ -103,14 +107,14 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
|
|||
});
|
||||
|
||||
return {
|
||||
refresh: () => {
|
||||
manualRefresh$.next();
|
||||
},
|
||||
license$: update$,
|
||||
refresh: refreshManually,
|
||||
license$,
|
||||
};
|
||||
}
|
||||
|
||||
public async start() {}
|
||||
public async start(core: CoreStart) {
|
||||
this.coreStart = core;
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.stop$.next();
|
||||
|
@ -119,13 +123,9 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
|
|||
if (this.removeInterceptor !== undefined) {
|
||||
this.removeInterceptor();
|
||||
}
|
||||
if (this.licenseFetchSubscription !== undefined) {
|
||||
this.licenseFetchSubscription.unsubscribe();
|
||||
this.licenseFetchSubscription = undefined;
|
||||
}
|
||||
if (this.storageSubscription !== undefined) {
|
||||
this.storageSubscription.unsubscribe();
|
||||
this.storageSubscription = undefined;
|
||||
if (this.internalSubscription !== undefined) {
|
||||
this.internalSubscription.unsubscribe();
|
||||
this.internalSubscription = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -136,7 +136,6 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
|
|||
'kbn-system-api': 'true',
|
||||
},
|
||||
});
|
||||
|
||||
return new License({
|
||||
license: response.license,
|
||||
features: response.features,
|
||||
|
@ -146,4 +145,16 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
|
|||
return new License({ error: error.message, signature: '' });
|
||||
}
|
||||
};
|
||||
|
||||
private showExpiredBanner(license: ILicense) {
|
||||
const uploadUrl = this.coreStart!.http.basePath.prepend(
|
||||
'/app/kibana#/management/elasticsearch/license_management/upload_license'
|
||||
);
|
||||
this.coreStart!.overlays.banners.add(
|
||||
mountExpiredBanner({
|
||||
type: license.type!,
|
||||
uploadUrl,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,10 +6,9 @@
|
|||
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
|
||||
const SECOND = 1000;
|
||||
export const config = {
|
||||
schema: schema.object({
|
||||
pollingFrequency: schema.number({ defaultValue: 30 * SECOND }),
|
||||
pollingFrequency: schema.duration({ defaultValue: '30s' }),
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { createOnPreResponseHandler } from './on_pre_response_handler';
|
||||
import { httpServiceMock, httpServerMock } from '../../../../src/core/server/mocks';
|
||||
import { licenseMock } from '../common/license.mock';
|
||||
|
||||
describe('createOnPreResponseHandler', () => {
|
||||
it('sets license.signature header immediately for non-error responses', async () => {
|
||||
const refresh = jest.fn();
|
||||
const license$ = new BehaviorSubject(licenseMock.create({ signature: 'foo' }));
|
||||
const toolkit = httpServiceMock.createOnPreResponseToolkit();
|
||||
|
||||
const interceptor = createOnPreResponseHandler(refresh, license$);
|
||||
await interceptor(httpServerMock.createKibanaRequest(), { statusCode: 200 }, toolkit);
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(0);
|
||||
expect(toolkit.next).toHaveBeenCalledTimes(1);
|
||||
expect(toolkit.next).toHaveBeenCalledWith({
|
||||
headers: {
|
||||
'kbn-license-sig': 'foo',
|
||||
},
|
||||
});
|
||||
});
|
||||
it('sets license.signature header after refresh for non-error responses', async () => {
|
||||
const updatedLicense = licenseMock.create({ signature: 'bar' });
|
||||
const license$ = new BehaviorSubject(licenseMock.create({ signature: 'foo' }));
|
||||
const refresh = jest.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
license$.next(updatedLicense);
|
||||
resolve();
|
||||
}, 50);
|
||||
})
|
||||
);
|
||||
|
||||
const toolkit = httpServiceMock.createOnPreResponseToolkit();
|
||||
|
||||
const interceptor = createOnPreResponseHandler(refresh, license$);
|
||||
await interceptor(httpServerMock.createKibanaRequest(), { statusCode: 400 }, toolkit);
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
expect(toolkit.next).toHaveBeenCalledTimes(1);
|
||||
expect(toolkit.next).toHaveBeenCalledWith({
|
||||
headers: {
|
||||
'kbn-license-sig': 'bar',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
30
x-pack/plugins/licensing/server/on_pre_response_handler.ts
Normal file
30
x-pack/plugins/licensing/server/on_pre_response_handler.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { Observable } from 'rxjs';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { OnPreResponseHandler } from '../../../../src/core/server';
|
||||
import { ILicense } from '../common/types';
|
||||
|
||||
export function createOnPreResponseHandler(
|
||||
refresh: () => Promise<ILicense>,
|
||||
license$: Observable<ILicense>
|
||||
): OnPreResponseHandler {
|
||||
return async (req, res, t) => {
|
||||
// If we're returning an error response, refresh license info from
|
||||
// Elasticsearch in case the error is due to a change in license information
|
||||
// in Elasticsearch.
|
||||
// https://github.com/elastic/x-pack-kibana/pull/2876
|
||||
if (res.statusCode >= 400) {
|
||||
await refresh();
|
||||
}
|
||||
const license = await license$.pipe(take(1)).toPromise();
|
||||
return t.next({
|
||||
headers: {
|
||||
'kbn-license-sig': license.signature,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { take, toArray } from 'rxjs/operators';
|
||||
import moment from 'moment';
|
||||
import { LicenseType } from '../common/types';
|
||||
import { ElasticsearchError, RawLicense } from './types';
|
||||
import { LicensingPlugin } from './plugin';
|
||||
|
@ -24,7 +25,7 @@ function buildRawLicense(options: Partial<RawLicense> = {}): RawLicense {
|
|||
};
|
||||
return Object.assign(defaultRawLicense, options);
|
||||
}
|
||||
const pollingFrequency = 100;
|
||||
const pollingFrequency = moment.duration(100);
|
||||
|
||||
const flushPromises = (ms = 50) => new Promise(res => setTimeout(res, ms));
|
||||
|
||||
|
@ -199,7 +200,7 @@ describe('licensing plugin', () => {
|
|||
plugin = new LicensingPlugin(
|
||||
coreMock.createPluginInitializerContext({
|
||||
// disable polling mechanism
|
||||
pollingFrequency: 50000,
|
||||
pollingFrequency: moment.duration(50000),
|
||||
})
|
||||
);
|
||||
const dataClient = elasticsearchServiceMock.createClusterClient();
|
||||
|
@ -251,6 +252,26 @@ describe('licensing plugin', () => {
|
|||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registers on pre-response interceptor', () => {
|
||||
let plugin: LicensingPlugin;
|
||||
|
||||
beforeEach(() => {
|
||||
plugin = new LicensingPlugin(coreMock.createPluginInitializerContext({ pollingFrequency }));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await plugin.stop();
|
||||
});
|
||||
|
||||
it('once', async () => {
|
||||
const coreSetup = coreMock.createSetup();
|
||||
|
||||
await plugin.setup(coreSetup);
|
||||
|
||||
expect(coreSetup.http.registerOnPreResponse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#stop', () => {
|
||||
|
@ -269,31 +290,5 @@ describe('licensing plugin', () => {
|
|||
await plugin.stop();
|
||||
expect(completed).toBe(true);
|
||||
});
|
||||
|
||||
it('refresh does not trigger data re-fetch', async () => {
|
||||
const plugin = new LicensingPlugin(
|
||||
coreMock.createPluginInitializerContext({
|
||||
pollingFrequency,
|
||||
})
|
||||
);
|
||||
|
||||
const dataClient = elasticsearchServiceMock.createClusterClient();
|
||||
dataClient.callAsInternalUser.mockResolvedValue({
|
||||
license: buildRawLicense(),
|
||||
features: {},
|
||||
});
|
||||
|
||||
const coreSetup = coreMock.createSetup();
|
||||
coreSetup.elasticsearch.dataClient$ = new BehaviorSubject(dataClient);
|
||||
|
||||
const { refresh } = await plugin.setup(coreSetup);
|
||||
|
||||
dataClient.callAsInternalUser.mockClear();
|
||||
|
||||
await plugin.stop();
|
||||
refresh();
|
||||
|
||||
expect(dataClient.callAsInternalUser).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,9 +4,9 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { Observable, Subject, Subscription, merge, timer } from 'rxjs';
|
||||
import { take, takeUntil } from 'rxjs/operators';
|
||||
import moment from 'moment';
|
||||
import { Observable, Subject, Subscription, timer } from 'rxjs';
|
||||
import { take } from 'rxjs/operators';
|
||||
import moment, { Duration } from 'moment';
|
||||
import { createHash } from 'crypto';
|
||||
import stringify from 'json-stable-stringify';
|
||||
|
||||
|
@ -28,6 +28,7 @@ import { registerRoutes } from './routes';
|
|||
|
||||
import { LicenseConfigType } from './licensing_config';
|
||||
import { createRouteHandlerContext } from './licensing_route_handler_context';
|
||||
import { createOnPreResponseHandler } from './on_pre_response_handler';
|
||||
|
||||
function normalizeServerLicense(license: RawLicense): PublicLicense {
|
||||
return {
|
||||
|
@ -78,7 +79,6 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
|
|||
private stop$ = new Subject();
|
||||
private readonly logger: Logger;
|
||||
private readonly config$: Observable<LicenseConfigType>;
|
||||
private licenseFetchSubscription?: Subscription;
|
||||
private loggingSubscription?: Subscription;
|
||||
|
||||
constructor(private readonly context: PluginInitializerContext) {
|
||||
|
@ -94,7 +94,9 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
|
|||
const { refresh, license$ } = this.createLicensePoller(dataClient, config.pollingFrequency);
|
||||
|
||||
core.http.registerRouteHandlerContext('licensing', createRouteHandlerContext(license$));
|
||||
|
||||
registerRoutes(core.http.createRouter());
|
||||
core.http.registerOnPreResponse(createOnPreResponseHandler(refresh, license$));
|
||||
|
||||
return {
|
||||
refresh,
|
||||
|
@ -102,17 +104,14 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
|
|||
};
|
||||
}
|
||||
|
||||
private createLicensePoller(clusterClient: IClusterClient, pollingFrequency: number) {
|
||||
const manualRefresh$ = new Subject();
|
||||
const intervalRefresh$ = timer(0, pollingFrequency);
|
||||
const refresh$ = merge(intervalRefresh$, manualRefresh$).pipe(takeUntil(this.stop$));
|
||||
private createLicensePoller(clusterClient: IClusterClient, pollingFrequency: Duration) {
|
||||
const intervalRefresh$ = timer(0, pollingFrequency.asMilliseconds());
|
||||
|
||||
const { update$, fetchSubscription } = createLicenseUpdate(refresh$, () =>
|
||||
const { license$, refreshManually } = createLicenseUpdate(intervalRefresh$, this.stop$, () =>
|
||||
this.fetchLicense(clusterClient)
|
||||
);
|
||||
|
||||
this.licenseFetchSubscription = fetchSubscription;
|
||||
this.loggingSubscription = update$.subscribe(license =>
|
||||
this.loggingSubscription = license$.subscribe(license =>
|
||||
this.logger.debug(
|
||||
'Imported license information from Elasticsearch:' +
|
||||
[
|
||||
|
@ -124,11 +123,11 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
|
|||
);
|
||||
|
||||
return {
|
||||
refresh: () => {
|
||||
refresh: async () => {
|
||||
this.logger.debug('Requesting Elasticsearch licensing API');
|
||||
manualRefresh$.next();
|
||||
return await refreshManually();
|
||||
},
|
||||
license$: update$,
|
||||
license$,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -139,8 +138,13 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
|
|||
path: '/_xpack',
|
||||
});
|
||||
|
||||
const normalizedLicense = normalizeServerLicense(response.license);
|
||||
const normalizedFeatures = normalizeFeatures(response.features);
|
||||
const normalizedLicense = response.license
|
||||
? normalizeServerLicense(response.license)
|
||||
: undefined;
|
||||
const normalizedFeatures = response.features
|
||||
? normalizeFeatures(response.features)
|
||||
: undefined;
|
||||
|
||||
const signature = sign({
|
||||
license: normalizedLicense,
|
||||
features: normalizedFeatures,
|
||||
|
@ -179,11 +183,6 @@ export class LicensingPlugin implements Plugin<LicensingPluginSetup> {
|
|||
this.stop$.next();
|
||||
this.stop$.complete();
|
||||
|
||||
if (this.licenseFetchSubscription !== undefined) {
|
||||
this.licenseFetchSubscription.unsubscribe();
|
||||
this.licenseFetchSubscription = undefined;
|
||||
}
|
||||
|
||||
if (this.loggingSubscription !== undefined) {
|
||||
this.loggingSubscription.unsubscribe();
|
||||
this.loggingSubscription = undefined;
|
||||
|
|
|
@ -124,7 +124,7 @@ describe('Session Timeout', () => {
|
|||
});
|
||||
|
||||
test(`starts and does not initialize on an anonymous path`, async () => {
|
||||
http.anonymousPaths.register(window.location.pathname);
|
||||
http.anonymousPaths.isAnonymous.mockReturnValue(true);
|
||||
await sessionTimeout.start();
|
||||
// eslint-disable-next-line dot-notation
|
||||
expect(sessionTimeout['channel']).toBeUndefined();
|
||||
|
|
|
@ -6396,9 +6396,6 @@
|
|||
"xpack.logstash.upgradeFailureActions.goBackButtonLabel": "戻る",
|
||||
"xpack.logstash.upstreamPipelineArgumentMustContainAnIdPropertyErrorMessage": "upstreamPipeline 引数には id プロパティを含める必要があります",
|
||||
"xpack.logstash.workersTooltip": "パイプラインのフィルターとアウトプットステージを同時に実行するワーカーの数です。イベントが詰まってしまう場合や CPU が飽和状態ではない場合は、マシンの処理能力をより有効に活用するため、この数字を上げてみてください。\n\nデフォルト値:ホストの CPU コア数です",
|
||||
"xpack.main.welcomeBanner.licenseIsExpiredDescription": "管理者または {updateYourLicenseLinkText} に直接お問い合わせください。",
|
||||
"xpack.main.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText": "ライセンスを更新",
|
||||
"xpack.main.welcomeBanner.licenseIsExpiredTitle": "ご使用の {licenseType} ライセンスは期限切れです",
|
||||
"xpack.maps.addLayerPanel.changeDataSourceButtonLabel": "データソースを変更",
|
||||
"xpack.maps.addLayerPanel.chooseDataSourceTitle": "データソースの選択",
|
||||
"xpack.maps.appDescription": "マップアプリケーション",
|
||||
|
@ -12750,6 +12747,9 @@
|
|||
"xpack.lens.xyVisualization.xyLabel": "XY",
|
||||
"xpack.licensing.check.errorExpiredMessage": "{licenseType} ライセンスが期限切れのため {pluginName} を使用できません。",
|
||||
"xpack.licensing.check.errorUnavailableMessage": "現在ライセンス情報が利用できないため {pluginName} を使用できません。",
|
||||
"xpack.licensing.check.errorUnsupportedMessage": "ご使用の {licenseType} ライセンスは {pluginName} をサポートしていません。ライセンスをアップグレードしてください。"
|
||||
"xpack.licensing.check.errorUnsupportedMessage": "ご使用の {licenseType} ライセンスは {pluginName} をサポートしていません。ライセンスをアップグレードしてください。",
|
||||
"xpack.licensing.welcomeBanner.licenseIsExpiredDescription": "管理者または {updateYourLicenseLinkText} に直接お問い合わせください。",
|
||||
"xpack.licensing.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText": "ライセンスを更新",
|
||||
"xpack.licensing.welcomeBanner.licenseIsExpiredTitle": "ご使用の {licenseType} ライセンスは期限切れです"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6398,9 +6398,6 @@
|
|||
"xpack.logstash.upgradeFailureActions.goBackButtonLabel": "返回",
|
||||
"xpack.logstash.upstreamPipelineArgumentMustContainAnIdPropertyErrorMessage": "upstreamPipeline 参数必须包含 id 属性",
|
||||
"xpack.logstash.workersTooltip": "并行执行管道的筛选和输出阶段的工作线程数目。如果您发现事件出现积压或 CPU 未饱和,请考虑增大此数值,以更好地利用机器处理能力。\n\n默认值:主机的 CPU 核心数",
|
||||
"xpack.main.welcomeBanner.licenseIsExpiredDescription": "联系您的管理员或直接{updateYourLicenseLinkText}。",
|
||||
"xpack.main.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText": "更新您的许可",
|
||||
"xpack.main.welcomeBanner.licenseIsExpiredTitle": "您的{licenseType}许可已过期",
|
||||
"xpack.maps.addLayerPanel.changeDataSourceButtonLabel": "更改数据源",
|
||||
"xpack.maps.addLayerPanel.chooseDataSourceTitle": "选择数据源",
|
||||
"xpack.maps.appDescription": "地图应用程序",
|
||||
|
@ -12839,6 +12836,9 @@
|
|||
"xpack.lens.xyVisualization.xyLabel": "XY",
|
||||
"xpack.licensing.check.errorExpiredMessage": "您不能使用 {pluginName},因为您的{licenseType}许可证已过期。",
|
||||
"xpack.licensing.check.errorUnavailableMessage": "您不能使用 {pluginName},因为许可证信息当前不可用。",
|
||||
"xpack.licensing.check.errorUnsupportedMessage": "您的{licenseType}许可证不支持 {pluginName}。请升级您的许可证。"
|
||||
"xpack.licensing.check.errorUnsupportedMessage": "您的{licenseType}许可证不支持 {pluginName}。请升级您的许可证。",
|
||||
"xpack.licensing.welcomeBanner.licenseIsExpiredDescription": "联系您的管理员或直接{updateYourLicenseLinkText}。",
|
||||
"xpack.licensing.welcomeBanner.licenseIsExpiredDescription.updateYourLicenseLinkText": "更新您的许可",
|
||||
"xpack.licensing.welcomeBanner.licenseIsExpiredTitle": "您的{licenseType}许可已过期"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,4 +35,5 @@ require('@kbn/test').runTestsCli([
|
|||
require.resolve('../test/ui_capabilities/security_only/config'),
|
||||
require.resolve('../test/ui_capabilities/spaces_only/config'),
|
||||
require.resolve('../test/upgrade_assistant_integration/config'),
|
||||
require.resolve('../test/licensing_plugin/config'),
|
||||
]);
|
||||
|
|
|
@ -27,7 +27,6 @@ export default function ({ loadTestFile }) {
|
|||
loadTestFile(require.resolve('./siem'));
|
||||
loadTestFile(require.resolve('./short_urls'));
|
||||
loadTestFile(require.resolve('./lens'));
|
||||
loadTestFile(require.resolve('./licensing'));
|
||||
loadTestFile(require.resolve('./endpoint'));
|
||||
});
|
||||
}
|
||||
|
|
136
x-pack/test/licensing_plugin/apis/changes.ts
Normal file
136
x-pack/test/licensing_plugin/apis/changes.ts
Normal file
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../services';
|
||||
import { PublicLicenseJSON } from '../../../plugins/licensing/server';
|
||||
|
||||
const delay = (ms: number) => new Promise(res => setTimeout(res, ms));
|
||||
|
||||
export default function({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const esSupertestWithoutAuth = getService('esSupertestWithoutAuth');
|
||||
const security = getService('security');
|
||||
const PageObjects = getPageObjects(['common', 'security']);
|
||||
const testSubjects = getService('testSubjects');
|
||||
|
||||
const scenario = {
|
||||
async setup() {
|
||||
await security.role.create('license_manager-role', {
|
||||
elasticsearch: {
|
||||
cluster: ['all'],
|
||||
},
|
||||
kibana: [
|
||||
{
|
||||
base: ['all'],
|
||||
spaces: ['*'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await security.user.create('license_manager_user', {
|
||||
password: 'license_manager_user-password',
|
||||
roles: ['license_manager-role'],
|
||||
full_name: 'license_manager user',
|
||||
});
|
||||
|
||||
// ensure we're logged out so we can login as the appropriate users
|
||||
await PageObjects.security.logout();
|
||||
await PageObjects.security.login('license_manager_user', 'license_manager_user-password');
|
||||
},
|
||||
|
||||
async teardown() {
|
||||
await security.role.delete('license_manager-role');
|
||||
},
|
||||
|
||||
async startBasic() {
|
||||
const response = await esSupertestWithoutAuth
|
||||
.post('/_license/start_basic?acknowledge=true')
|
||||
.auth('license_manager_user', 'license_manager_user-password')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.basic_was_started).to.be(true);
|
||||
},
|
||||
|
||||
async startTrial() {
|
||||
const response = await esSupertestWithoutAuth
|
||||
.post('/_license/start_trial?acknowledge=true')
|
||||
.auth('license_manager_user', 'license_manager_user-password')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.trial_was_started).to.be(true);
|
||||
},
|
||||
|
||||
async deleteLicense() {
|
||||
const response = await esSupertestWithoutAuth
|
||||
.delete('/_license')
|
||||
.auth('license_manager_user', 'license_manager_user-password')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.acknowledged).to.be(true);
|
||||
},
|
||||
|
||||
async getLicense(): Promise<PublicLicenseJSON> {
|
||||
// > --xpack.licensing.pollingFrequency set in test config
|
||||
// to wait for Kibana server to re-fetch the license from Elasticsearch
|
||||
await delay(1000);
|
||||
|
||||
const { body } = await supertest.get('/api/licensing/info').expect(200);
|
||||
return body;
|
||||
},
|
||||
};
|
||||
|
||||
describe('changes in license types', () => {
|
||||
after(async () => {
|
||||
await scenario.startBasic();
|
||||
});
|
||||
|
||||
it('provides changes in license types', async () => {
|
||||
await scenario.setup();
|
||||
const initialLicense = await scenario.getLicense();
|
||||
expect(initialLicense.license?.type).to.be('basic');
|
||||
// security enabled explicitly in test config
|
||||
expect(initialLicense.features?.security).to.eql({
|
||||
isAvailable: true,
|
||||
isEnabled: true,
|
||||
});
|
||||
|
||||
const refetchedLicense = await scenario.getLicense();
|
||||
expect(refetchedLicense.license?.type).to.be('basic');
|
||||
expect(refetchedLicense.signature).to.be(initialLicense.signature);
|
||||
|
||||
// server allows to request trial only once.
|
||||
// other attempts will throw 403
|
||||
await scenario.startTrial();
|
||||
const trialLicense = await scenario.getLicense();
|
||||
expect(trialLicense.license?.type).to.be('trial');
|
||||
expect(trialLicense.signature).to.not.be(initialLicense.signature);
|
||||
expect(trialLicense.features?.security).to.eql({
|
||||
isAvailable: true,
|
||||
isEnabled: true,
|
||||
});
|
||||
|
||||
await scenario.startBasic();
|
||||
const basicLicense = await scenario.getLicense();
|
||||
expect(basicLicense.license?.type).to.be('basic');
|
||||
expect(basicLicense.signature).not.to.be(initialLicense.signature);
|
||||
expect(trialLicense.features?.security).to.eql({
|
||||
isAvailable: true,
|
||||
isEnabled: true,
|
||||
});
|
||||
|
||||
await scenario.deleteLicense();
|
||||
const inactiveLicense = await scenario.getLicense();
|
||||
expect(inactiveLicense.signature).to.not.be(initialLicense.signature);
|
||||
expect(inactiveLicense).to.not.have.property('license');
|
||||
expect(inactiveLicense.features?.security).to.eql({
|
||||
isAvailable: false,
|
||||
isEnabled: true,
|
||||
});
|
||||
// banner shown only when license expired not just deleted
|
||||
await testSubjects.missingOrFail('licenseExpiredBanner');
|
||||
});
|
||||
});
|
||||
}
|
21
x-pack/test/licensing_plugin/apis/header.ts
Normal file
21
x-pack/test/licensing_plugin/apis/header.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../services';
|
||||
|
||||
export default function({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('Header', () => {
|
||||
it("Injects 'kbn-license-sig' header to the all responses", async () => {
|
||||
const response = await supertest.get('/');
|
||||
|
||||
expect(response.header).property('kbn-license-sig');
|
||||
expect(response.header['kbn-license-sig']).to.not.be.empty();
|
||||
});
|
||||
});
|
||||
}
|
18
x-pack/test/licensing_plugin/apis/index.ts
Normal file
18
x-pack/test/licensing_plugin/apis/index.ts
Normal 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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../services';
|
||||
|
||||
export default function({ loadTestFile }: FtrProviderContext) {
|
||||
describe('Licensing plugin', function() {
|
||||
this.tags('ciGroup2');
|
||||
loadTestFile(require.resolve('./info'));
|
||||
loadTestFile(require.resolve('./header'));
|
||||
|
||||
// MUST BE LAST! CHANGES LICENSE TYPE!
|
||||
loadTestFile(require.resolve('./changes'));
|
||||
});
|
||||
}
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
import { FtrProviderContext } from '../services';
|
||||
|
||||
export default function({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
@ -19,6 +19,12 @@ export default function({ getService }: FtrProviderContext) {
|
|||
expect(response.body).property('license');
|
||||
expect(response.body).property('signature');
|
||||
});
|
||||
|
||||
it('returns a correct license type', async () => {
|
||||
const response = await supertest.get('/api/licensing/info').expect(200);
|
||||
|
||||
expect(response.body.license.type).to.be('basic');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
54
x-pack/test/licensing_plugin/config.ts
Normal file
54
x-pack/test/licensing_plugin/config.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { FtrConfigProviderContext } from '@kbn/test/types/ftr';
|
||||
import { services, pageObjects } from './services';
|
||||
|
||||
const license = 'basic';
|
||||
|
||||
export default async function({ readConfigFile }: FtrConfigProviderContext) {
|
||||
const functionalTestsConfig = await readConfigFile(require.resolve('../functional/config.js'));
|
||||
|
||||
const servers = {
|
||||
...functionalTestsConfig.get('servers'),
|
||||
elasticsearch: {
|
||||
...functionalTestsConfig.get('servers.elasticsearch'),
|
||||
},
|
||||
kibana: {
|
||||
...functionalTestsConfig.get('servers.kibana'),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
testFiles: [require.resolve('./apis')],
|
||||
servers,
|
||||
services,
|
||||
pageObjects,
|
||||
junit: {
|
||||
reportName: 'License plugin API Integration Tests',
|
||||
},
|
||||
|
||||
esTestCluster: {
|
||||
...functionalTestsConfig.get('esTestCluster'),
|
||||
license,
|
||||
serverArgs: [
|
||||
...functionalTestsConfig.get('esTestCluster.serverArgs'),
|
||||
'xpack.security.enabled=true',
|
||||
],
|
||||
},
|
||||
|
||||
kbnTestServer: {
|
||||
...functionalTestsConfig.get('kbnTestServer'),
|
||||
serverArgs: [
|
||||
...functionalTestsConfig.get('kbnTestServer.serverArgs'),
|
||||
'--xpack.licensing.pollingFrequency=300',
|
||||
],
|
||||
},
|
||||
|
||||
apps: {
|
||||
...functionalTestsConfig.get('apps'),
|
||||
},
|
||||
};
|
||||
}
|
20
x-pack/test/licensing_plugin/services.ts
Normal file
20
x-pack/test/licensing_plugin/services.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { GenericFtrProviderContext } from '@kbn/test/types/ftr';
|
||||
|
||||
import { services as functionalTestServices } from '../functional/services';
|
||||
import { services as kibanaApiIntegrationServices } from '../api_integration/services';
|
||||
import { pageObjects } from '../functional/page_objects';
|
||||
|
||||
export const services = {
|
||||
...functionalTestServices,
|
||||
supertest: kibanaApiIntegrationServices.supertest,
|
||||
esSupertestWithoutAuth: kibanaApiIntegrationServices.esSupertestWithoutAuth,
|
||||
};
|
||||
|
||||
export { pageObjects };
|
||||
|
||||
export type FtrProviderContext = GenericFtrProviderContext<typeof services, typeof pageObjects>;
|
Loading…
Reference in a new issue