mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[Spaces] Space solution property (#183986)
## Summary Added solution property for the space. - Forbidden in serverless. - To facilitate iterative development made the property as optional in stateful offering until all of the workstreams are complete. ### How to test API changes ``` # Should create space POST kbn:/api/spaces/space { "name": "space without solution", "id": "my-space-solution-1", "description": "a description", "color": "#5c5959", "disabledFeatures": [] } # Should fail with 400 POST kbn:/api/spaces/space { "name": "space with solution", "id": "my-space-solution-2", "description": "a description", "color": "#5c5959", "solution": "some_solution", "disabledFeatures": [] } # Should fail with 400 POST kbn:/api/spaces/space { "name": "space with solution", "id": "my-space-solution-2", "description": "a description", "color": "#5c5959", "solution": null, "disabledFeatures": [] } # Should create space POST kbn:/api/spaces/space { "name": "space with solution", "id": "my-space-solution-2", "description": "a description", "color": "#5c5959", "solution": "search", "disabledFeatures": [] } # Should get 'my-space-solution-1' space without solution field GET kbn:/api/spaces/space/my-space-solution-1 # Should get 'my-space-solution-2' space with solution field GET kbn:/api/spaces/space/my-space-solution-2 # Should fail to update with 400 PUT kbn:/api/spaces/space/my-space-solution-1 { "id": "my-space-solution-1", "name": "my-space-solution-1 name", "solution": "some_solution" } # Should fail to update with 400 PUT kbn:/api/spaces/space/my-space-solution-1 { "id": "my-space-solution-1", "name": "my-space-solution-1 name", "solution": null } # Should update 'my-space-solution-1' PUT kbn:/api/spaces/space/my-space-solution-1 { "id": "my-space-solution-1", "name": "my-space-solution-1 name", "solution": "security" } # Should get 'my-space-solution-1' space wit solution field set to 'security' GET kbn:/api/spaces/space/my-space-solution-1 # Should return list where # 1. 'my-space-solution-1' has solution 'security' # 2. 'my-space-solution-2' has solution 'search' # 3. Other spaces don't have solution field present GET kbn:/api/spaces/space ``` ### Checklist - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed ([Security and Spaces config](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/6076), [Spaces only config](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/6075)) ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) __Fixes: https://github.com/elastic/kibana/issues/183559__ ## Release note Added optional solution property for Space in a stateful offering. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
982dfa3551
commit
afb3d37469
36 changed files with 605 additions and 89 deletions
|
@ -31,6 +31,7 @@ The API returns the following:
|
|||
"color": "#aabbcc",
|
||||
"initials": "MK",
|
||||
"disabledFeatures": [],
|
||||
"imageUrl": ""
|
||||
"imageUrl": "",
|
||||
"solution": "search"
|
||||
}
|
||||
--------------------------------------------------
|
||||
|
|
|
@ -71,7 +71,8 @@ The API returns the following:
|
|||
"name": "Sales",
|
||||
"initials": "MK",
|
||||
"disabledFeatures": ["discover"],
|
||||
"imageUrl": ""
|
||||
"imageUrl": "",
|
||||
"solution": "observability"
|
||||
}
|
||||
]
|
||||
--------------------------------------------------
|
||||
|
|
|
@ -36,6 +36,9 @@ experimental[] Create a {kib} space.
|
|||
(Optional, string) The data-URL encoded image to display in the space avatar. If specified, `initials` will not be displayed, and the `color` will be visible as the background color for transparent images.
|
||||
For best results, your image should be 64x64. Images will not be optimized by this API call, so care should be taken when using custom images.
|
||||
|
||||
`solution`::
|
||||
(Optional, string) The solution defined for the space. Can be one of `security`, `observability`, `search`, `classic`
|
||||
|
||||
[[spaces-api-post-response-codes]]
|
||||
==== Response codes
|
||||
|
||||
|
|
|
@ -36,6 +36,9 @@ experimental[] Update an existing {kib} space.
|
|||
(Optional, string) Specifies the data-url encoded image to display in the space avatar. If specified, `initials` will not be displayed, and the `color` will be visible as the background color for transparent images.
|
||||
For best results, your image should be 64x64. Images will not be optimized by this API call, so care should be taken when using custom images.
|
||||
|
||||
`solution`::
|
||||
(Optional, string) The solution defined for the space. Can be one of `security`, `observability`, `search`, `classic`.
|
||||
|
||||
[[spaces-api-put-response-codes]]
|
||||
==== Response codes
|
||||
|
||||
|
|
|
@ -931,7 +931,8 @@
|
|||
],
|
||||
"slo-settings": [],
|
||||
"space": [
|
||||
"name"
|
||||
"name",
|
||||
"solution"
|
||||
],
|
||||
"spaces-usage-stats": [],
|
||||
"synthetics-monitor": [
|
||||
|
|
|
@ -3062,6 +3062,9 @@
|
|||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"solution": {
|
||||
"type": "keyword"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -149,7 +149,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
|
|||
"siem-ui-timeline-pinned-event": "082daa3ce647b33873f6abccf340bdfa32057c8d",
|
||||
"slo": "9a9995e4572de1839651c43b5fc4dc8276bb5815",
|
||||
"slo-settings": "f6b5ed339470a6a2cda272bde1750adcf504a11b",
|
||||
"space": "8de4ec513e9bbc6b2f1d635161d850be7747d38e",
|
||||
"space": "d38fa4bc669b9b1d6ec86aac2983d4c6675723ed",
|
||||
"spaces-usage-stats": "3abca98713c52af8b30300e386c7779b3025a20e",
|
||||
"synthetics-monitor": "5ceb25b6249bd26902c9b34273c71c3dce06dbea",
|
||||
"synthetics-param": "3ebb744e5571de678b1312d5c418c8188002cf5e",
|
||||
|
|
|
@ -15,6 +15,7 @@ import { Env } from '@kbn/config';
|
|||
import { getEnvOptions } from '@kbn/config-mocks';
|
||||
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
||||
import type { SavedObjectsRawDoc } from '@kbn/core-saved-objects-server';
|
||||
import { modelVersionToVirtualVersion } from '@kbn/core-saved-objects-base-server-internal';
|
||||
import {
|
||||
createTestServers,
|
||||
createRootWithCorePlugins,
|
||||
|
@ -125,7 +126,7 @@ describe('migrating from 7.3.0-xpack which used v1 migrations', () => {
|
|||
.getTypeRegistry()
|
||||
.getAllTypes()
|
||||
.reduce((versionMap, type) => {
|
||||
const { name, migrations, convertToMultiNamespaceTypeVersion } = type;
|
||||
const { name, migrations, convertToMultiNamespaceTypeVersion, modelVersions } = type;
|
||||
if (migrations || convertToMultiNamespaceTypeVersion) {
|
||||
const migrationsMap = typeof migrations === 'function' ? migrations() : migrations;
|
||||
const migrationsKeys = migrationsMap ? Object.keys(migrationsMap) : [];
|
||||
|
@ -133,6 +134,16 @@ describe('migrating from 7.3.0-xpack which used v1 migrations', () => {
|
|||
// Setting this option registers a conversion migration that is reflected in the object's `typeMigrationVersions` field
|
||||
migrationsKeys.push(convertToMultiNamespaceTypeVersion);
|
||||
}
|
||||
|
||||
const modelVersionCreateSchemas =
|
||||
typeof modelVersions === 'function' ? modelVersions() : modelVersions ?? {};
|
||||
|
||||
Object.entries(modelVersionCreateSchemas).forEach(([key, modelVersion]) => {
|
||||
if (modelVersion.schemas?.create) {
|
||||
migrationsKeys.push(modelVersionToVirtualVersion(key));
|
||||
}
|
||||
});
|
||||
|
||||
const highestVersion = migrationsKeys.sort(Semver.compare).reverse()[0];
|
||||
return {
|
||||
...versionMap,
|
||||
|
|
|
@ -58,6 +58,11 @@ export interface Space {
|
|||
* @private
|
||||
*/
|
||||
_reserved?: boolean;
|
||||
|
||||
/**
|
||||
* Solution selected for this space.
|
||||
*/
|
||||
solution?: 'security' | 'observability' | 'search' | 'classic';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -5,7 +5,11 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { spaceSchema } from './space_schema';
|
||||
import { getSpaceSchema } from './space_schema';
|
||||
|
||||
// non-serverless space schema
|
||||
const spaceBaseSchema = getSpaceSchema(false);
|
||||
const spaceServerlessSchema = getSpaceSchema(true);
|
||||
|
||||
const defaultProperties = {
|
||||
id: 'foo',
|
||||
|
@ -15,7 +19,7 @@ const defaultProperties = {
|
|||
describe('#id', () => {
|
||||
test('is required', () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
id: undefined,
|
||||
})
|
||||
|
@ -26,7 +30,7 @@ describe('#id', () => {
|
|||
|
||||
test('allows lowercase a-z, 0-9, "_" and "-"', () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
id: 'abcdefghijklmnopqrstuvwxyz0123456789_-',
|
||||
})
|
||||
|
@ -35,7 +39,7 @@ describe('#id', () => {
|
|||
|
||||
test(`doesn't allow uppercase`, () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
id: 'Foo',
|
||||
})
|
||||
|
@ -46,7 +50,7 @@ describe('#id', () => {
|
|||
|
||||
test(`doesn't allow an empty string`, () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
id: '',
|
||||
})
|
||||
|
@ -59,7 +63,7 @@ describe('#id', () => {
|
|||
(invalidCharacter) => {
|
||||
test(`doesn't allow ${invalidCharacter}`, () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
id: `foo-${invalidCharacter}`,
|
||||
})
|
||||
|
@ -72,7 +76,7 @@ describe('#id', () => {
|
|||
describe('#disabledFeatures', () => {
|
||||
test('is optional', () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
disabledFeatures: undefined,
|
||||
})
|
||||
|
@ -80,7 +84,7 @@ describe('#disabledFeatures', () => {
|
|||
});
|
||||
|
||||
test('defaults to an empty array', () => {
|
||||
const result = spaceSchema.validate({
|
||||
const result = spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
disabledFeatures: undefined,
|
||||
});
|
||||
|
@ -89,7 +93,7 @@ describe('#disabledFeatures', () => {
|
|||
|
||||
test('must be an array if provided', () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
disabledFeatures: 'foo',
|
||||
})
|
||||
|
@ -100,7 +104,7 @@ describe('#disabledFeatures', () => {
|
|||
|
||||
test('allows an array of strings', () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
disabledFeatures: ['foo', 'bar'],
|
||||
})
|
||||
|
@ -109,7 +113,7 @@ describe('#disabledFeatures', () => {
|
|||
|
||||
test('does not allow an array containing non-string elements', () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
disabledFeatures: ['foo', true],
|
||||
})
|
||||
|
@ -122,7 +126,7 @@ describe('#disabledFeatures', () => {
|
|||
describe('#color', () => {
|
||||
test('is optional', () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
color: undefined,
|
||||
})
|
||||
|
@ -131,7 +135,7 @@ describe('#color', () => {
|
|||
|
||||
test(`doesn't allow an empty string`, () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
color: '',
|
||||
})
|
||||
|
@ -142,7 +146,7 @@ describe('#color', () => {
|
|||
|
||||
test(`allows lower case hex color code`, () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
color: '#aabbcc',
|
||||
})
|
||||
|
@ -151,7 +155,7 @@ describe('#color', () => {
|
|||
|
||||
test(`allows upper case hex color code`, () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
color: '#AABBCC',
|
||||
})
|
||||
|
@ -160,7 +164,7 @@ describe('#color', () => {
|
|||
|
||||
test(`allows numeric hex color code`, () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
color: '#123456',
|
||||
})
|
||||
|
@ -169,7 +173,7 @@ describe('#color', () => {
|
|||
|
||||
test(`must start with a hash`, () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
color: '123456',
|
||||
})
|
||||
|
@ -180,7 +184,7 @@ describe('#color', () => {
|
|||
|
||||
test(`cannot exceed 6 digits following the hash`, () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
color: '1234567',
|
||||
})
|
||||
|
@ -191,7 +195,7 @@ describe('#color', () => {
|
|||
|
||||
test(`cannot be fewer than 6 digits following the hash`, () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
color: '12345',
|
||||
})
|
||||
|
@ -204,7 +208,7 @@ describe('#color', () => {
|
|||
describe('#imageUrl', () => {
|
||||
test('is optional', () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
imageUrl: undefined,
|
||||
})
|
||||
|
@ -213,7 +217,7 @@ describe('#imageUrl', () => {
|
|||
|
||||
test(`must start with data:image`, () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
imageUrl: 'notValid',
|
||||
})
|
||||
|
@ -222,7 +226,7 @@ describe('#imageUrl', () => {
|
|||
|
||||
test(`checking that a valid image is accepted as imageUrl`, () => {
|
||||
expect(() =>
|
||||
spaceSchema.validate({
|
||||
spaceBaseSchema.validate({
|
||||
...defaultProperties,
|
||||
imageUrl:
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMTnU1rJkAAAB3klEQVRYR+2WzUrDQBCARzwqehE8ir1WPfgqRRA1bePBXgpe/MGCB9/Aiw+j+ASCB6kotklaEwW1F0WwNSaps9lV69awGzBpDzt8pJP9mXxsmk3ABH2oUEIilJAIJSRCCYlQQiKUkIh4QgY5agZodVjBowFrBktWQzDBU2ykiYaDuQpCYgnl3QunGzM6Z6YF+b5SkcgK1UH/aLbYReQiYL9d9/o+XFop5IU0Vl4uapAzoXC3eEBPw9vH1/wT6Vs2otPSkoH/IZzlzO/TU2vgQm8nl69Hp0H7nZ4OXogLJSSKBIUC3w88n+Ueyfv56fVZnqCQNVnCHbLrkV0Gd2d+GNkglsk438dhaTxloZDutV4wb06Vf40JcWZ2sMttPpE8NaHGeBnzIAhwPXqHseVB11EyLD0hxLUeaYud2a3B0g3k7GyFtrhX7F2RqhC+yV3jgTb2Rqdqf7/kUxYiWBOlTtXxfPJEtc8b5thGb+8AhL4ohnCNqQjZ2T2+K5rnw2M6KwEhKNDSGM3pTdxjhDgLbHkw/v/zw4AiPuSsfMzAiTidKxiF/ArpFqyzK8SMOlkwvloUMYRCtNvZLWeuIomd2Za/WZS4QomjhEQoIRFKSIQSEqGERAyfEH4YDBFQ/ARU6BiBxCAIQQAAAABJRU5ErkJggg==',
|
||||
|
@ -230,3 +234,32 @@ describe('#imageUrl', () => {
|
|||
).not.toThrowError();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#solution', () => {
|
||||
it('should throw error if solution is defined in serverless offering', () => {
|
||||
expect(() =>
|
||||
spaceServerlessSchema.validate({ ...defaultProperties, solution: 'search' })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should not throw error if solution is undefined in classic offering', () => {
|
||||
expect(() =>
|
||||
spaceBaseSchema.validate({ ...defaultProperties, solution: undefined }, {})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error if solution is invalid in classic offering', () => {
|
||||
expect(() => spaceBaseSchema.validate({ ...defaultProperties, solution: 'some_value' }, {}))
|
||||
.toThrowErrorMatchingInlineSnapshot(`
|
||||
"[solution]: types that failed validation:
|
||||
- [solution.0]: expected value to equal [security]
|
||||
- [solution.1]: expected value to equal [observability]
|
||||
- [solution.2]: expected value to equal [search]
|
||||
- [solution.3]: expected value to equal [classic]"
|
||||
`);
|
||||
|
||||
expect(() =>
|
||||
spaceBaseSchema.validate({ ...defaultProperties, solution: ' search ' }, {})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -11,7 +11,7 @@ import { MAX_SPACE_INITIALS } from '../../common';
|
|||
|
||||
export const SPACE_ID_REGEX = /^[a-z0-9_\-]+$/;
|
||||
|
||||
export const spaceSchema = schema.object({
|
||||
const spaceSchema = schema.object({
|
||||
id: schema.string({
|
||||
validate: (value) => {
|
||||
if (!SPACE_ID_REGEX.test(value)) {
|
||||
|
@ -43,3 +43,18 @@ export const spaceSchema = schema.object({
|
|||
})
|
||||
),
|
||||
});
|
||||
|
||||
const solutionSchema = schema.oneOf([
|
||||
schema.literal('security'),
|
||||
schema.literal('observability'),
|
||||
schema.literal('search'),
|
||||
schema.literal('classic'),
|
||||
]);
|
||||
|
||||
export const getSpaceSchema = (isServerless: boolean) => {
|
||||
if (isServerless) {
|
||||
return spaceSchema;
|
||||
}
|
||||
|
||||
return spaceSchema.extends({ solution: schema.maybe(solutionSchema) });
|
||||
};
|
||||
|
|
|
@ -125,7 +125,10 @@ export class SpacesPlugin
|
|||
this.hasOnlyDefaultSpace$ = this.config$.pipe(map(({ maxSpaces }) => maxSpaces === 1));
|
||||
this.log = initializerContext.logger.get();
|
||||
this.spacesService = new SpacesService();
|
||||
this.spacesClientService = new SpacesClientService((message) => this.log.debug(message));
|
||||
this.spacesClientService = new SpacesClientService(
|
||||
(message) => this.log.debug(message),
|
||||
initializerContext.env.packageInfo.buildFlavor
|
||||
);
|
||||
}
|
||||
|
||||
public setup(core: CoreSetup<PluginsStart>, plugins: PluginsSetup): SpacesPluginSetup {
|
||||
|
@ -168,16 +171,14 @@ export class SpacesPlugin
|
|||
|
||||
const router = core.http.createRouter<SpacesRequestHandlerContext>();
|
||||
|
||||
initExternalSpacesApi(
|
||||
{
|
||||
router,
|
||||
log: this.log,
|
||||
getStartServices: core.getStartServices,
|
||||
getSpacesService,
|
||||
usageStatsServicePromise,
|
||||
},
|
||||
this.initializerContext.env.packageInfo.buildFlavor
|
||||
);
|
||||
initExternalSpacesApi({
|
||||
router,
|
||||
log: this.log,
|
||||
getStartServices: core.getStartServices,
|
||||
getSpacesService,
|
||||
usageStatsServicePromise,
|
||||
isServerless: this.initializerContext.env.packageInfo.buildFlavor === 'serverless',
|
||||
});
|
||||
|
||||
initInternalSpacesApi({
|
||||
router,
|
||||
|
|
|
@ -57,7 +57,7 @@ describe('copy to space', () => {
|
|||
createResolveSavedObjectsImportErrorsMock()
|
||||
);
|
||||
|
||||
const clientService = new SpacesClientService(jest.fn());
|
||||
const clientService = new SpacesClientService(jest.fn(), 'traditional');
|
||||
clientService
|
||||
.setup({ config$: Rx.of(spacesConfig) })
|
||||
.setClientRepositoryFactory(() => savedObjectsRepositoryMock);
|
||||
|
@ -85,6 +85,7 @@ describe('copy to space', () => {
|
|||
log,
|
||||
getSpacesService: () => spacesServiceStart,
|
||||
usageStatsServicePromise,
|
||||
isServerless: false,
|
||||
});
|
||||
|
||||
const [[ctsRouteDefinition, ctsRouteHandler], [resolveRouteDefinition, resolveRouteHandler]] =
|
||||
|
|
|
@ -42,7 +42,7 @@ describe('Spaces Public API', () => {
|
|||
|
||||
const coreStart = coreMock.createStart();
|
||||
|
||||
const clientService = new SpacesClientService(jest.fn());
|
||||
const clientService = new SpacesClientService(jest.fn(), 'traditional');
|
||||
clientService
|
||||
.setup({ config$: Rx.of(spacesConfig) })
|
||||
.setClientRepositoryFactory(() => savedObjectsRepositoryMock);
|
||||
|
@ -67,6 +67,7 @@ describe('Spaces Public API', () => {
|
|||
log,
|
||||
getSpacesService: () => spacesServiceStart,
|
||||
usageStatsServicePromise,
|
||||
isServerless: false,
|
||||
});
|
||||
|
||||
const [routeDefinition, routeHandler] = router.delete.mock.calls[0];
|
||||
|
|
|
@ -42,7 +42,7 @@ describe('_disable_legacy_url_aliases', () => {
|
|||
|
||||
const log = loggingSystemMock.create().get('spaces');
|
||||
|
||||
const clientService = new SpacesClientService(jest.fn());
|
||||
const clientService = new SpacesClientService(jest.fn(), 'traditional');
|
||||
clientService
|
||||
.setup({ config$: Rx.of(spacesConfig) })
|
||||
.setClientRepositoryFactory(() => savedObjectsRepositoryMock);
|
||||
|
@ -70,6 +70,7 @@ describe('_disable_legacy_url_aliases', () => {
|
|||
log,
|
||||
getSpacesService: () => spacesServiceStart,
|
||||
usageStatsServicePromise,
|
||||
isServerless: false,
|
||||
});
|
||||
|
||||
const [routeDefinition, routeHandler] = router.post.mock.calls[0];
|
||||
|
|
|
@ -41,7 +41,7 @@ describe('GET space', () => {
|
|||
|
||||
const log = loggingSystemMock.create().get('spaces');
|
||||
|
||||
const clientService = new SpacesClientService(jest.fn());
|
||||
const clientService = new SpacesClientService(jest.fn(), 'traditional');
|
||||
clientService
|
||||
.setup({ config$: Rx.of(spacesConfig) })
|
||||
.setClientRepositoryFactory(() => savedObjectsRepositoryMock);
|
||||
|
@ -66,6 +66,7 @@ describe('GET space', () => {
|
|||
log,
|
||||
getSpacesService: () => spacesServiceStart,
|
||||
usageStatsServicePromise,
|
||||
isServerless: false,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -30,7 +30,9 @@ export function initGetSpaceApi(deps: ExternalRouteDeps) {
|
|||
|
||||
try {
|
||||
const space = await spacesClient.get(spaceId);
|
||||
return response.ok({ body: space });
|
||||
return response.ok({
|
||||
body: space,
|
||||
});
|
||||
} catch (error) {
|
||||
if (SavedObjectsErrorHelpers.isNotFoundError(error)) {
|
||||
return response.notFound();
|
||||
|
|
|
@ -43,7 +43,7 @@ describe('GET /spaces/space', () => {
|
|||
|
||||
const log = loggingSystemMock.create().get('spaces');
|
||||
|
||||
const clientService = new SpacesClientService(jest.fn());
|
||||
const clientService = new SpacesClientService(jest.fn(), 'traditional');
|
||||
clientService
|
||||
.setup({ config$: Rx.of(spacesConfig) })
|
||||
.setClientRepositoryFactory(() => savedObjectsRepositoryMock);
|
||||
|
@ -68,6 +68,7 @@ describe('GET /spaces/space', () => {
|
|||
log,
|
||||
getSpacesService: () => spacesServiceStart,
|
||||
usageStatsServicePromise,
|
||||
isServerless: false,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -42,7 +42,7 @@ describe('get shareable references', () => {
|
|||
const { savedObjects, savedObjectsClient } = createMockSavedObjectsService(spaces);
|
||||
coreStart.savedObjects = savedObjects;
|
||||
|
||||
const clientService = new SpacesClientService(jest.fn());
|
||||
const clientService = new SpacesClientService(jest.fn(), 'traditional');
|
||||
clientService
|
||||
.setup({ config$: Rx.of(spacesConfig) })
|
||||
.setClientRepositoryFactory(() => savedObjectsRepositoryMock);
|
||||
|
@ -66,6 +66,7 @@ describe('get shareable references', () => {
|
|||
log,
|
||||
getSpacesService: () => spacesServiceStart,
|
||||
usageStatsServicePromise,
|
||||
isServerless: false,
|
||||
});
|
||||
|
||||
const [[getShareableReferences, getShareableReferencesRouteHandler]] = router.post.mock.calls;
|
||||
|
|
|
@ -4,8 +4,6 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { BuildFlavor } from '@kbn/config/src/types';
|
||||
import type { CoreSetup, Logger } from '@kbn/core/server';
|
||||
|
||||
import { initCopyToSpacesApi } from './copy_to_space';
|
||||
|
@ -27,9 +25,10 @@ export interface ExternalRouteDeps {
|
|||
getSpacesService: () => SpacesServiceStart;
|
||||
usageStatsServicePromise: Promise<UsageStatsServiceSetup>;
|
||||
log: Logger;
|
||||
isServerless: boolean;
|
||||
}
|
||||
|
||||
export function initExternalSpacesApi(deps: ExternalRouteDeps, buildFlavor: BuildFlavor) {
|
||||
export function initExternalSpacesApi(deps: ExternalRouteDeps) {
|
||||
// These two routes are always registered, internal in serverless by default
|
||||
initGetSpaceApi(deps);
|
||||
initGetAllSpacesApi(deps);
|
||||
|
@ -37,7 +36,7 @@ export function initExternalSpacesApi(deps: ExternalRouteDeps, buildFlavor: Buil
|
|||
// In the serverless environment, Spaces are enabled but are effectively hidden from the user. We
|
||||
// do not support more than 1 space: the default space. These HTTP APIs for creating, deleting,
|
||||
// updating, and manipulating saved objects across multiple spaces are not needed.
|
||||
if (buildFlavor !== 'serverless') {
|
||||
if (!deps.isServerless) {
|
||||
initPutSpacesApi(deps);
|
||||
initDeleteSpacesApi(deps);
|
||||
initPostSpacesApi(deps);
|
||||
|
|
|
@ -42,7 +42,7 @@ describe('Spaces Public API', () => {
|
|||
|
||||
const log = loggingSystemMock.create().get('spaces');
|
||||
|
||||
const clientService = new SpacesClientService(jest.fn());
|
||||
const clientService = new SpacesClientService(jest.fn(), 'traditional');
|
||||
clientService
|
||||
.setup({ config$: Rx.of(spacesConfig) })
|
||||
.setClientRepositoryFactory(() => savedObjectsRepositoryMock);
|
||||
|
@ -67,6 +67,7 @@ describe('Spaces Public API', () => {
|
|||
log,
|
||||
getSpacesService: () => spacesServiceStart,
|
||||
usageStatsServicePromise,
|
||||
isServerless: false,
|
||||
});
|
||||
|
||||
const [routeDefinition, routeHandler] = router.post.mock.calls[0];
|
||||
|
|
|
@ -11,17 +11,17 @@ import { SavedObjectsErrorHelpers } from '@kbn/core/server';
|
|||
|
||||
import type { ExternalRouteDeps } from '.';
|
||||
import { wrapError } from '../../../lib/errors';
|
||||
import { spaceSchema } from '../../../lib/space_schema';
|
||||
import { getSpaceSchema } from '../../../lib/space_schema';
|
||||
import { createLicensedRouteHandler } from '../../lib';
|
||||
|
||||
export function initPostSpacesApi(deps: ExternalRouteDeps) {
|
||||
const { router, log, getSpacesService } = deps;
|
||||
const { router, log, getSpacesService, isServerless } = deps;
|
||||
|
||||
router.post(
|
||||
{
|
||||
path: '/api/spaces/space',
|
||||
validate: {
|
||||
body: spaceSchema,
|
||||
body: getSpaceSchema(isServerless),
|
||||
},
|
||||
},
|
||||
createLicensedRouteHandler(async (context, request, response) => {
|
||||
|
|
|
@ -42,7 +42,7 @@ describe('PUT /api/spaces/space', () => {
|
|||
|
||||
const log = loggingSystemMock.create().get('spaces');
|
||||
|
||||
const clientService = new SpacesClientService(jest.fn());
|
||||
const clientService = new SpacesClientService(jest.fn(), 'traditional');
|
||||
clientService
|
||||
.setup({ config$: Rx.of(spacesConfig) })
|
||||
.setClientRepositoryFactory(() => savedObjectsRepositoryMock);
|
||||
|
@ -67,6 +67,7 @@ describe('PUT /api/spaces/space', () => {
|
|||
log,
|
||||
getSpacesService: () => spacesServiceStart,
|
||||
usageStatsServicePromise,
|
||||
isServerless: false,
|
||||
});
|
||||
|
||||
const [routeDefinition, routeHandler] = router.put.mock.calls[0];
|
||||
|
|
|
@ -11,11 +11,11 @@ import { SavedObjectsErrorHelpers } from '@kbn/core/server';
|
|||
import type { ExternalRouteDeps } from '.';
|
||||
import type { Space } from '../../../../common';
|
||||
import { wrapError } from '../../../lib/errors';
|
||||
import { spaceSchema } from '../../../lib/space_schema';
|
||||
import { getSpaceSchema } from '../../../lib/space_schema';
|
||||
import { createLicensedRouteHandler } from '../../lib';
|
||||
|
||||
export function initPutSpacesApi(deps: ExternalRouteDeps) {
|
||||
const { router, getSpacesService } = deps;
|
||||
const { router, getSpacesService, isServerless } = deps;
|
||||
|
||||
router.put(
|
||||
{
|
||||
|
@ -24,7 +24,7 @@ export function initPutSpacesApi(deps: ExternalRouteDeps) {
|
|||
params: schema.object({
|
||||
id: schema.string(),
|
||||
}),
|
||||
body: spaceSchema,
|
||||
body: getSpaceSchema(isServerless),
|
||||
},
|
||||
},
|
||||
createLicensedRouteHandler(async (context, request, response) => {
|
||||
|
|
|
@ -43,7 +43,7 @@ describe('update_objects_spaces', () => {
|
|||
const { savedObjects, savedObjectsClient } = createMockSavedObjectsService(spaces);
|
||||
coreStart.savedObjects = savedObjects;
|
||||
|
||||
const clientService = new SpacesClientService(jest.fn());
|
||||
const clientService = new SpacesClientService(jest.fn(), 'traditional');
|
||||
clientService
|
||||
.setup({ config$: Rx.of(spacesConfig) })
|
||||
.setClientRepositoryFactory(() => savedObjectsRepositoryMock);
|
||||
|
@ -67,6 +67,7 @@ describe('update_objects_spaces', () => {
|
|||
log,
|
||||
getSpacesService: () => spacesServiceStart,
|
||||
usageStatsServicePromise,
|
||||
isServerless: false,
|
||||
});
|
||||
|
||||
const [[updateObjectsSpaces, updateObjectsSpacesRouteHandler]] = router.post.mock.calls;
|
||||
|
|
|
@ -45,7 +45,7 @@ describe('GET /internal/spaces/{spaceId}/content_summary', () => {
|
|||
|
||||
const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects);
|
||||
|
||||
const clientService = new SpacesClientService(jest.fn());
|
||||
const clientService = new SpacesClientService(jest.fn(), 'traditional');
|
||||
clientService
|
||||
.setup({ config$: Rx.of(spacesConfig) })
|
||||
.setClientRepositoryFactory(() => savedObjectsRepositoryMock);
|
||||
|
|
|
@ -19,6 +19,9 @@ export const SpacesSavedObjectMappings = deepFreeze({
|
|||
},
|
||||
},
|
||||
},
|
||||
solution: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
} as const);
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import type { CoreSetup } from '@kbn/core/server';
|
||||
|
||||
import { SpacesSavedObjectMappings, UsageStatsMappings } from './mappings';
|
||||
|
@ -30,6 +31,30 @@ export class SpacesSavedObjectsService {
|
|||
migrations: {
|
||||
'6.6.0': spaceMigrations.migrateTo660,
|
||||
},
|
||||
modelVersions: {
|
||||
1: {
|
||||
changes: [
|
||||
{
|
||||
type: 'mappings_addition',
|
||||
addedMappings: {
|
||||
solution: { type: 'keyword' },
|
||||
},
|
||||
},
|
||||
],
|
||||
schemas: {
|
||||
create: SpacesSavedObjectSchemas['8.8.0'].extends({
|
||||
solution: schema.maybe(
|
||||
schema.oneOf([
|
||||
schema.literal('security'),
|
||||
schema.literal('observability'),
|
||||
schema.literal('search'),
|
||||
schema.literal('classic'),
|
||||
])
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
core.savedObjects.registerType({
|
||||
|
|
|
@ -42,6 +42,7 @@ describe('#getAll', () => {
|
|||
imageUrl: 'go-bots/predates/transformers',
|
||||
disabledFeatures: [],
|
||||
_reserved: true,
|
||||
solution: 'search',
|
||||
bar: 'foo-bar', // an extra attribute that will be ignored during conversion
|
||||
},
|
||||
},
|
||||
|
@ -80,6 +81,7 @@ describe('#getAll', () => {
|
|||
initials: 'FB',
|
||||
imageUrl: 'go-bots/predates/transformers',
|
||||
disabledFeatures: [],
|
||||
solution: 'search',
|
||||
_reserved: true,
|
||||
},
|
||||
{
|
||||
|
@ -105,7 +107,13 @@ describe('#getAll', () => {
|
|||
} as any);
|
||||
const mockConfig = createMockConfig();
|
||||
|
||||
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
|
||||
const client = new SpacesClient(
|
||||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[],
|
||||
'traditional'
|
||||
);
|
||||
const actualSpaces = await client.getAll();
|
||||
|
||||
expect(actualSpaces).toEqual(expectedSpaces);
|
||||
|
@ -117,11 +125,46 @@ describe('#getAll', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('strips solution property in serverless build', async () => {
|
||||
const mockDebugLogger = createMockDebugLogger();
|
||||
const [SOWithSolution] = savedObjects;
|
||||
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
|
||||
mockCallWithRequestRepository.find.mockResolvedValue({
|
||||
saved_objects: [SOWithSolution],
|
||||
} as any);
|
||||
const mockConfig = createMockConfig();
|
||||
|
||||
const client = new SpacesClient(
|
||||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[],
|
||||
'serverless'
|
||||
);
|
||||
const [actualSpace] = await client.getAll();
|
||||
const [{ solution, ...expectedSpace }] = expectedSpaces;
|
||||
|
||||
expect(actualSpace.solution).toBeUndefined();
|
||||
expect(actualSpace).toEqual(expectedSpace);
|
||||
expect(mockCallWithRequestRepository.find).toHaveBeenCalledWith({
|
||||
type: 'space',
|
||||
page: 1,
|
||||
perPage: mockConfig.maxSpaces,
|
||||
sortField: 'name.keyword',
|
||||
});
|
||||
});
|
||||
|
||||
test(`throws Boom.badRequest when an invalid purpose is provided'`, async () => {
|
||||
const mockDebugLogger = createMockDebugLogger();
|
||||
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
|
||||
const mockConfig = createMockConfig();
|
||||
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
|
||||
const client = new SpacesClient(
|
||||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[],
|
||||
'traditional'
|
||||
);
|
||||
await expect(
|
||||
client.getAll({ purpose: 'invalid_purpose' as GetAllSpacesPurpose })
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(`"unsupported space purpose: invalid_purpose"`);
|
||||
|
@ -162,13 +205,64 @@ describe('#get', () => {
|
|||
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
|
||||
mockCallWithRequestRepository.get.mockResolvedValue(savedObject);
|
||||
|
||||
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
|
||||
const client = new SpacesClient(
|
||||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[],
|
||||
'traditional'
|
||||
);
|
||||
const id = savedObject.id;
|
||||
const actualSpace = await client.get(id);
|
||||
|
||||
expect(actualSpace).toEqual(expectedSpace);
|
||||
expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id);
|
||||
});
|
||||
|
||||
test('strips solution property in serverless build', async () => {
|
||||
const mockDebugLogger = createMockDebugLogger();
|
||||
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
|
||||
mockCallWithRequestRepository.get.mockResolvedValue({
|
||||
...savedObject,
|
||||
attributes: { ...(savedObject.attributes as Record<string, unknown>), solution: 'search' },
|
||||
});
|
||||
const mockConfig = createMockConfig();
|
||||
|
||||
const client = new SpacesClient(
|
||||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[],
|
||||
'serverless'
|
||||
);
|
||||
const id = savedObject.id;
|
||||
const actualSpace = await client.get(id);
|
||||
|
||||
expect(actualSpace.solution).toBeUndefined();
|
||||
expect(actualSpace).toEqual(expectedSpace);
|
||||
});
|
||||
|
||||
test(`doesn't strip solution property in traditional build`, async () => {
|
||||
const mockDebugLogger = createMockDebugLogger();
|
||||
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
|
||||
mockCallWithRequestRepository.get.mockResolvedValue({
|
||||
...savedObject,
|
||||
attributes: { ...(savedObject.attributes as Record<string, unknown>), solution: 'search' },
|
||||
});
|
||||
const mockConfig = createMockConfig();
|
||||
|
||||
const client = new SpacesClient(
|
||||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[],
|
||||
'traditional'
|
||||
);
|
||||
const id = savedObject.id;
|
||||
const actualSpace = await client.get(id);
|
||||
|
||||
expect(actualSpace).toEqual({ ...expectedSpace, solution: 'search' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('#create', () => {
|
||||
|
@ -219,7 +313,13 @@ describe('#create', () => {
|
|||
allowFeatureVisibility: true,
|
||||
});
|
||||
|
||||
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
|
||||
const client = new SpacesClient(
|
||||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[],
|
||||
'traditional'
|
||||
);
|
||||
|
||||
const actualSpace = await client.create(spaceToCreate);
|
||||
|
||||
|
@ -249,7 +349,13 @@ describe('#create', () => {
|
|||
allowFeatureVisibility: true,
|
||||
});
|
||||
|
||||
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
|
||||
const client = new SpacesClient(
|
||||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[],
|
||||
'traditional'
|
||||
);
|
||||
|
||||
await expect(client.create(spaceToCreate)).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"Unable to create Space, this exceeds the maximum number of spaces set by the xpack.spaces.maxSpaces setting"`
|
||||
|
@ -263,6 +369,93 @@ describe('#create', () => {
|
|||
expect(mockCallWithRequestRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('throws bad request when solution property is provided in serverless build', async () => {
|
||||
const maxSpaces = 5;
|
||||
const mockDebugLogger = createMockDebugLogger();
|
||||
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
|
||||
mockCallWithRequestRepository.create.mockResolvedValue(savedObject);
|
||||
mockCallWithRequestRepository.find.mockResolvedValue({
|
||||
total: maxSpaces - 1,
|
||||
} as any);
|
||||
|
||||
const mockConfig = createMockConfig({
|
||||
enabled: true,
|
||||
maxSpaces,
|
||||
allowFeatureVisibility: true,
|
||||
});
|
||||
|
||||
const client = new SpacesClient(
|
||||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[],
|
||||
'serverless'
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.create({ ...spaceToCreate, solution: undefined })
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"Unable to create Space, solution property is forbidden in serverless"`
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.create({ ...spaceToCreate, solution: 'search' })
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"Unable to create Space, solution property is forbidden in serverless"`
|
||||
);
|
||||
|
||||
expect(mockCallWithRequestRepository.find).toHaveBeenCalledWith({
|
||||
type: 'space',
|
||||
page: 1,
|
||||
perPage: 0,
|
||||
});
|
||||
expect(mockCallWithRequestRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('creates space when solution property is provided in traditional build', async () => {
|
||||
const maxSpaces = 5;
|
||||
const mockDebugLogger = createMockDebugLogger();
|
||||
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
|
||||
mockCallWithRequestRepository.create.mockResolvedValue({
|
||||
...savedObject,
|
||||
attributes: { ...(savedObject.attributes as Record<string, unknown>), solution: 'search' },
|
||||
});
|
||||
mockCallWithRequestRepository.find.mockResolvedValue({
|
||||
total: maxSpaces - 1,
|
||||
} as any);
|
||||
|
||||
const mockConfig = createMockConfig({
|
||||
enabled: true,
|
||||
maxSpaces,
|
||||
allowFeatureVisibility: true,
|
||||
});
|
||||
|
||||
const client = new SpacesClient(
|
||||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[],
|
||||
'traditional'
|
||||
);
|
||||
|
||||
const actualSpace = await client.create({ ...spaceToCreate, solution: 'search' });
|
||||
|
||||
expect(actualSpace).toEqual({ ...expectedReturnedSpace, solution: 'search' });
|
||||
|
||||
expect(mockCallWithRequestRepository.find).toHaveBeenCalledWith({
|
||||
type: 'space',
|
||||
page: 1,
|
||||
perPage: 0,
|
||||
});
|
||||
expect(mockCallWithRequestRepository.create).toHaveBeenCalledWith(
|
||||
'space',
|
||||
{ ...attributes, solution: 'search' },
|
||||
{
|
||||
id,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('when config.allowFeatureVisibility is disabled', () => {
|
||||
test(`creates space without disabledFeatures`, async () => {
|
||||
const maxSpaces = 5;
|
||||
|
@ -283,7 +476,8 @@ describe('#create', () => {
|
|||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[]
|
||||
[],
|
||||
'traditional'
|
||||
);
|
||||
|
||||
const actualSpace = await client.create(spaceToCreate);
|
||||
|
@ -318,7 +512,8 @@ describe('#create', () => {
|
|||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[]
|
||||
[],
|
||||
'traditional'
|
||||
);
|
||||
|
||||
await expect(
|
||||
|
@ -377,7 +572,13 @@ describe('#update', () => {
|
|||
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
|
||||
mockCallWithRequestRepository.get.mockResolvedValue(savedObject);
|
||||
|
||||
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
|
||||
const client = new SpacesClient(
|
||||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[],
|
||||
'traditional'
|
||||
);
|
||||
const id = savedObject.id;
|
||||
const actualSpace = await client.update(id, spaceToUpdate);
|
||||
|
||||
|
@ -386,6 +587,87 @@ describe('#update', () => {
|
|||
expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id);
|
||||
});
|
||||
|
||||
test('throws bad request when solution property is provided in serverless build', async () => {
|
||||
const mockDebugLogger = createMockDebugLogger();
|
||||
const mockConfig = createMockConfig();
|
||||
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
|
||||
mockCallWithRequestRepository.get.mockResolvedValue(savedObject);
|
||||
|
||||
const client = new SpacesClient(
|
||||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[],
|
||||
'serverless'
|
||||
);
|
||||
const id = savedObject.id;
|
||||
|
||||
await expect(
|
||||
client.update(id, { ...spaceToUpdate, solution: undefined })
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"Unable to update Space, solution property is forbidden in serverless"`
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.update(id, { ...spaceToUpdate, solution: 'search' })
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"Unable to update Space, solution property is forbidden in serverless"`
|
||||
);
|
||||
|
||||
expect(mockCallWithRequestRepository.update).not.toHaveBeenCalled();
|
||||
|
||||
expect(mockCallWithRequestRepository.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('throws bad request when solution property is undefined in traditional build', async () => {
|
||||
const mockDebugLogger = createMockDebugLogger();
|
||||
const mockConfig = createMockConfig();
|
||||
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
|
||||
mockCallWithRequestRepository.get.mockResolvedValue(savedObject);
|
||||
|
||||
const client = new SpacesClient(
|
||||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[],
|
||||
'traditional'
|
||||
);
|
||||
const id = savedObject.id;
|
||||
|
||||
await expect(
|
||||
client.update(id, { ...spaceToUpdate, solution: undefined })
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"Unable to update Space, solution property cannot be empty"`
|
||||
);
|
||||
|
||||
expect(mockCallWithRequestRepository.update).not.toHaveBeenCalled();
|
||||
|
||||
expect(mockCallWithRequestRepository.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('updates space with solution property using callWithRequestRepository in traditional build', async () => {
|
||||
const mockDebugLogger = createMockDebugLogger();
|
||||
const mockConfig = createMockConfig();
|
||||
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
|
||||
mockCallWithRequestRepository.get.mockResolvedValue(savedObject);
|
||||
|
||||
const client = new SpacesClient(
|
||||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[],
|
||||
'traditional'
|
||||
);
|
||||
const id = savedObject.id;
|
||||
await client.update(id, { ...spaceToUpdate, solution: 'search' });
|
||||
|
||||
expect(mockCallWithRequestRepository.update).toHaveBeenCalledWith('space', id, {
|
||||
...attributes,
|
||||
solution: 'search',
|
||||
});
|
||||
expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id);
|
||||
});
|
||||
|
||||
describe('when config.allowFeatureVisibility is disabled', () => {
|
||||
test(`updates space without disabledFeatures`, async () => {
|
||||
const mockDebugLogger = createMockDebugLogger();
|
||||
|
@ -401,7 +683,8 @@ describe('#update', () => {
|
|||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[]
|
||||
[],
|
||||
'traditional'
|
||||
);
|
||||
const id = savedObject.id;
|
||||
const actualSpace = await client.update(id, spaceToUpdate);
|
||||
|
@ -425,7 +708,8 @@ describe('#update', () => {
|
|||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[]
|
||||
[],
|
||||
'traditional'
|
||||
);
|
||||
const id = savedObject.id;
|
||||
|
||||
|
@ -473,7 +757,13 @@ describe('#delete', () => {
|
|||
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
|
||||
mockCallWithRequestRepository.get.mockResolvedValue(reservedSavedObject);
|
||||
|
||||
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
|
||||
const client = new SpacesClient(
|
||||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[],
|
||||
'traditional'
|
||||
);
|
||||
|
||||
await expect(client.delete(id)).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"The foo space cannot be deleted because it is reserved."`
|
||||
|
@ -488,7 +778,13 @@ describe('#delete', () => {
|
|||
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
|
||||
mockCallWithRequestRepository.get.mockResolvedValue(notReservedSavedObject);
|
||||
|
||||
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
|
||||
const client = new SpacesClient(
|
||||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[],
|
||||
'traditional'
|
||||
);
|
||||
|
||||
await client.delete(id);
|
||||
|
||||
|
@ -504,7 +800,13 @@ describe('#disableLegacyUrlAliases', () => {
|
|||
const mockConfig = createMockConfig();
|
||||
const mockCallWithRequestRepository = savedObjectsRepositoryMock.create();
|
||||
|
||||
const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository, []);
|
||||
const client = new SpacesClient(
|
||||
mockDebugLogger,
|
||||
mockConfig,
|
||||
mockCallWithRequestRepository,
|
||||
[],
|
||||
'traditional'
|
||||
);
|
||||
const aliases = [
|
||||
{ targetSpace: 'space1', targetType: 'foo', sourceId: '123' },
|
||||
{ targetSpace: 'space2', targetType: 'bar', sourceId: '456' },
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import Boom from '@hapi/boom';
|
||||
|
||||
import type { BuildFlavor } from '@kbn/config/src/types';
|
||||
import type {
|
||||
ISavedObjectsPointInTimeFinder,
|
||||
ISavedObjectsRepository,
|
||||
|
@ -80,12 +81,17 @@ export interface ISpacesClient {
|
|||
* Client for interacting with spaces.
|
||||
*/
|
||||
export class SpacesClient implements ISpacesClient {
|
||||
private isServerless = false;
|
||||
|
||||
constructor(
|
||||
private readonly debugLogger: (message: string) => void,
|
||||
private readonly config: ConfigType,
|
||||
private readonly repository: ISavedObjectsRepository,
|
||||
private readonly nonGlobalTypeNames: string[]
|
||||
) {}
|
||||
private readonly nonGlobalTypeNames: string[],
|
||||
private readonly buildFlavour: BuildFlavor
|
||||
) {
|
||||
this.isServerless = this.buildFlavour === 'serverless';
|
||||
}
|
||||
|
||||
public async getAll(options: v1.GetAllSpacesOptions = {}): Promise<v1.GetSpaceResult[]> {
|
||||
const { purpose = DEFAULT_PURPOSE } = options;
|
||||
|
@ -130,10 +136,19 @@ export class SpacesClient implements ISpacesClient {
|
|||
);
|
||||
}
|
||||
|
||||
if (this.isServerless && space.hasOwnProperty('solution')) {
|
||||
throw Boom.badRequest('Unable to create Space, solution property is forbidden in serverless');
|
||||
}
|
||||
|
||||
if (space.hasOwnProperty('solution') && !space.solution) {
|
||||
throw Boom.badRequest('Unable to create Space, solution property cannot be empty');
|
||||
}
|
||||
|
||||
this.debugLogger(`SpacesClient.create(), using RBAC. Attempting to create space`);
|
||||
|
||||
const id = space.id;
|
||||
const attributes = this.generateSpaceAttributes(space);
|
||||
|
||||
const createdSavedObject = await this.repository.create('space', attributes, { id });
|
||||
|
||||
this.debugLogger(`SpacesClient.create(), created space object`);
|
||||
|
@ -148,6 +163,14 @@ export class SpacesClient implements ISpacesClient {
|
|||
);
|
||||
}
|
||||
|
||||
if (this.isServerless && space.hasOwnProperty('solution')) {
|
||||
throw Boom.badRequest('Unable to update Space, solution property is forbidden in serverless');
|
||||
}
|
||||
|
||||
if (space.hasOwnProperty('solution') && !space.solution) {
|
||||
throw Boom.badRequest('Unable to update Space, solution property cannot be empty');
|
||||
}
|
||||
|
||||
const attributes = this.generateSpaceAttributes(space);
|
||||
await this.repository.update('space', id, attributes);
|
||||
const updatedSavedObject = await this.repository.get('space', id);
|
||||
|
@ -181,7 +204,7 @@ export class SpacesClient implements ISpacesClient {
|
|||
await this.repository.bulkUpdate(objectsToUpdate);
|
||||
}
|
||||
|
||||
private transformSavedObjectToSpace(savedObject: SavedObject<any>): v1.Space {
|
||||
private transformSavedObjectToSpace = (savedObject: SavedObject<any>): v1.Space => {
|
||||
return {
|
||||
id: savedObject.id,
|
||||
name: savedObject.attributes.name ?? '',
|
||||
|
@ -191,10 +214,11 @@ export class SpacesClient implements ISpacesClient {
|
|||
imageUrl: savedObject.attributes.imageUrl,
|
||||
disabledFeatures: savedObject.attributes.disabledFeatures ?? [],
|
||||
_reserved: savedObject.attributes._reserved,
|
||||
...(!this.isServerless ? { solution: savedObject.attributes.solution } : {}),
|
||||
} as v1.Space;
|
||||
}
|
||||
};
|
||||
|
||||
private generateSpaceAttributes(space: v1.Space) {
|
||||
private generateSpaceAttributes = (space: v1.Space) => {
|
||||
return {
|
||||
name: space.name,
|
||||
description: space.description,
|
||||
|
@ -202,6 +226,7 @@ export class SpacesClient implements ISpacesClient {
|
|||
initials: space.initials,
|
||||
imageUrl: space.imageUrl,
|
||||
disabledFeatures: space.disabledFeatures,
|
||||
...(!this.isServerless && space.solution ? { solution: space.solution } : {}),
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ const debugLogger = jest.fn();
|
|||
describe('SpacesClientService', () => {
|
||||
describe('#setup', () => {
|
||||
it('allows a single repository factory to be set', () => {
|
||||
const service = new SpacesClientService(debugLogger);
|
||||
const service = new SpacesClientService(debugLogger, 'traditional');
|
||||
const setup = service.setup({ config$: Rx.of(spacesConfig) });
|
||||
|
||||
const repositoryFactory = jest.fn();
|
||||
|
@ -32,7 +32,7 @@ describe('SpacesClientService', () => {
|
|||
});
|
||||
|
||||
it('allows a single client wrapper to be set', () => {
|
||||
const service = new SpacesClientService(debugLogger);
|
||||
const service = new SpacesClientService(debugLogger, 'traditional');
|
||||
const setup = service.setup({ config$: Rx.of(spacesConfig) });
|
||||
|
||||
const clientWrapper = jest.fn();
|
||||
|
@ -46,7 +46,7 @@ describe('SpacesClientService', () => {
|
|||
|
||||
describe('#start', () => {
|
||||
it('throws if config is not available', () => {
|
||||
const service = new SpacesClientService(debugLogger);
|
||||
const service = new SpacesClientService(debugLogger, 'traditional');
|
||||
service.setup({ config$: new Rx.Observable<ConfigType>() });
|
||||
const coreStart = coreMock.createStart();
|
||||
const start = service.start(coreStart);
|
||||
|
@ -60,7 +60,7 @@ describe('SpacesClientService', () => {
|
|||
|
||||
describe('without a custom repository factory or wrapper', () => {
|
||||
it('returns an instance of the spaces client using the scoped repository', () => {
|
||||
const service = new SpacesClientService(debugLogger);
|
||||
const service = new SpacesClientService(debugLogger, 'traditional');
|
||||
service.setup({ config$: Rx.of(spacesConfig) });
|
||||
|
||||
const coreStart = coreMock.createStart();
|
||||
|
@ -78,7 +78,7 @@ describe('SpacesClientService', () => {
|
|||
});
|
||||
|
||||
it('uses the custom repository factory when set', () => {
|
||||
const service = new SpacesClientService(debugLogger);
|
||||
const service = new SpacesClientService(debugLogger, 'traditional');
|
||||
const setup = service.setup({ config$: Rx.of(spacesConfig) });
|
||||
|
||||
const customRepositoryFactory = jest.fn();
|
||||
|
@ -98,7 +98,7 @@ describe('SpacesClientService', () => {
|
|||
});
|
||||
|
||||
it('wraps the client in the wrapper when registered', () => {
|
||||
const service = new SpacesClientService(debugLogger);
|
||||
const service = new SpacesClientService(debugLogger, 'traditional');
|
||||
const setup = service.setup({ config$: Rx.of(spacesConfig) });
|
||||
|
||||
const wrapper = Symbol() as unknown as ISpacesClient;
|
||||
|
@ -123,7 +123,7 @@ describe('SpacesClientService', () => {
|
|||
});
|
||||
|
||||
it('wraps the client in the wrapper when registered, using the custom repository factory when configured', () => {
|
||||
const service = new SpacesClientService(debugLogger);
|
||||
const service = new SpacesClientService(debugLogger, 'traditional');
|
||||
const setup = service.setup({ config$: Rx.of(spacesConfig) });
|
||||
|
||||
const customRepositoryFactory = jest.fn();
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import type { Observable } from 'rxjs';
|
||||
|
||||
import type { BuildFlavor } from '@kbn/config/src/types';
|
||||
import type {
|
||||
CoreStart,
|
||||
ISavedObjectsRepository,
|
||||
|
@ -72,7 +73,10 @@ export class SpacesClientService {
|
|||
|
||||
private clientWrapper?: SpacesClientWrapper;
|
||||
|
||||
constructor(private readonly debugLogger: (message: string) => void) {}
|
||||
constructor(
|
||||
private readonly debugLogger: (message: string) => void,
|
||||
private readonly buildFlavour: BuildFlavor
|
||||
) {}
|
||||
|
||||
public setup({ config$ }: SetupDeps): SpacesClientServiceSetup {
|
||||
config$.subscribe((nextConfig) => {
|
||||
|
@ -117,7 +121,8 @@ export class SpacesClientService {
|
|||
this.debugLogger,
|
||||
this.config,
|
||||
this.repositoryFactory!(request, coreStart.savedObjects),
|
||||
nonGlobalTypeNames
|
||||
nonGlobalTypeNames,
|
||||
this.buildFlavour
|
||||
);
|
||||
if (this.clientWrapper) {
|
||||
return this.clientWrapper(request, baseClient);
|
||||
|
|
|
@ -69,7 +69,7 @@ const createService = (serverBasePath: string = '') => {
|
|||
basePath: httpSetup.basePath,
|
||||
});
|
||||
|
||||
const spacesClientService = new SpacesClientService(jest.fn());
|
||||
const spacesClientService = new SpacesClientService(jest.fn(), 'traditional');
|
||||
spacesClientService.setup({
|
||||
config$: Rx.of(spacesConfig),
|
||||
});
|
||||
|
|
|
@ -19,6 +19,7 @@ interface CreateTests {
|
|||
newSpace: CreateTest;
|
||||
alreadyExists: CreateTest;
|
||||
reservedSpecified: CreateTest;
|
||||
solutionSpecified: CreateTest;
|
||||
}
|
||||
|
||||
interface CreateTestDefinition {
|
||||
|
@ -63,6 +64,17 @@ export function createTestSuiteFactory(esArchiver: any, supertest: SuperTest<any
|
|||
});
|
||||
};
|
||||
|
||||
const expectSolutionSpecifiedResult = (resp: Record<string, any>) => {
|
||||
expect(resp.body).to.eql({
|
||||
id: 'solution',
|
||||
name: 'space with solution',
|
||||
description: 'a description',
|
||||
color: '#5c5959',
|
||||
disabledFeatures: [],
|
||||
solution: 'search',
|
||||
});
|
||||
};
|
||||
|
||||
const makeCreateTest =
|
||||
(describeFn: DescribeFn) =>
|
||||
(description: string, { user = {}, spaceId, tests }: CreateTestDefinition) => {
|
||||
|
@ -128,6 +140,24 @@ export function createTestSuiteFactory(esArchiver: any, supertest: SuperTest<any
|
|||
.then(tests.reservedSpecified.response);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when solution is specified', () => {
|
||||
it(`should return ${tests.solutionSpecified.statusCode}`, async () => {
|
||||
return supertest
|
||||
.post(`${urlPrefix}/api/spaces/space`)
|
||||
.auth(user.username, user.password)
|
||||
.send({
|
||||
name: 'space with solution',
|
||||
id: 'solution',
|
||||
description: 'a description',
|
||||
color: '#5c5959',
|
||||
solution: 'search',
|
||||
disabledFeatures: [],
|
||||
})
|
||||
.expect(tests.solutionSpecified.statusCode)
|
||||
.then(tests.solutionSpecified.response);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
@ -142,5 +172,6 @@ export function createTestSuiteFactory(esArchiver: any, supertest: SuperTest<any
|
|||
expectNewSpaceResult,
|
||||
expectRbacForbiddenResponse,
|
||||
expectReservedSpecifiedResult,
|
||||
expectSolutionSpecifiedResult,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ export default function createSpacesOnlySuite({ getService }: FtrProviderContext
|
|||
expectReservedSpecifiedResult,
|
||||
expectConflictResponse,
|
||||
expectRbacForbiddenResponse,
|
||||
expectSolutionSpecifiedResult,
|
||||
} = createTestSuiteFactory(esArchiver, supertestWithoutAuth);
|
||||
|
||||
describe('create', () => {
|
||||
|
@ -68,6 +69,10 @@ export default function createSpacesOnlySuite({ getService }: FtrProviderContext
|
|||
statusCode: 403,
|
||||
response: expectRbacForbiddenResponse,
|
||||
},
|
||||
solutionSpecified: {
|
||||
statusCode: 403,
|
||||
response: expectRbacForbiddenResponse,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -87,6 +92,10 @@ export default function createSpacesOnlySuite({ getService }: FtrProviderContext
|
|||
statusCode: 200,
|
||||
response: expectReservedSpecifiedResult,
|
||||
},
|
||||
solutionSpecified: {
|
||||
statusCode: 200,
|
||||
response: expectSolutionSpecifiedResult,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -106,6 +115,10 @@ export default function createSpacesOnlySuite({ getService }: FtrProviderContext
|
|||
statusCode: 200,
|
||||
response: expectReservedSpecifiedResult,
|
||||
},
|
||||
solutionSpecified: {
|
||||
statusCode: 200,
|
||||
response: expectSolutionSpecifiedResult,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -125,6 +138,10 @@ export default function createSpacesOnlySuite({ getService }: FtrProviderContext
|
|||
statusCode: 200,
|
||||
response: expectReservedSpecifiedResult,
|
||||
},
|
||||
solutionSpecified: {
|
||||
statusCode: 200,
|
||||
response: expectSolutionSpecifiedResult,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -144,6 +161,10 @@ export default function createSpacesOnlySuite({ getService }: FtrProviderContext
|
|||
statusCode: 403,
|
||||
response: expectRbacForbiddenResponse,
|
||||
},
|
||||
solutionSpecified: {
|
||||
statusCode: 403,
|
||||
response: expectRbacForbiddenResponse,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -163,6 +184,10 @@ export default function createSpacesOnlySuite({ getService }: FtrProviderContext
|
|||
statusCode: 403,
|
||||
response: expectRbacForbiddenResponse,
|
||||
},
|
||||
solutionSpecified: {
|
||||
statusCode: 403,
|
||||
response: expectRbacForbiddenResponse,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -182,6 +207,10 @@ export default function createSpacesOnlySuite({ getService }: FtrProviderContext
|
|||
statusCode: 403,
|
||||
response: expectRbacForbiddenResponse,
|
||||
},
|
||||
solutionSpecified: {
|
||||
statusCode: 403,
|
||||
response: expectRbacForbiddenResponse,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -201,6 +230,10 @@ export default function createSpacesOnlySuite({ getService }: FtrProviderContext
|
|||
statusCode: 403,
|
||||
response: expectRbacForbiddenResponse,
|
||||
},
|
||||
solutionSpecified: {
|
||||
statusCode: 403,
|
||||
response: expectRbacForbiddenResponse,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
@ -19,6 +19,7 @@ export default function createSpacesOnlySuite({ getService }: FtrProviderContext
|
|||
expectNewSpaceResult,
|
||||
expectConflictResponse,
|
||||
expectReservedSpecifiedResult,
|
||||
expectSolutionSpecifiedResult,
|
||||
} = createTestSuiteFactory(esArchiver, supertestWithoutAuth);
|
||||
|
||||
describe('create', () => {
|
||||
|
@ -45,6 +46,10 @@ export default function createSpacesOnlySuite({ getService }: FtrProviderContext
|
|||
statusCode: 200,
|
||||
response: expectReservedSpecifiedResult,
|
||||
},
|
||||
solutionSpecified: {
|
||||
statusCode: 200,
|
||||
response: expectSolutionSpecifiedResult,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue