Basic server side export API for saved objects (#30326) (#32513)

* Initial work for new server side export API

* Revert UI changes, API only in this PR

* Remove whitespace at top of export.asciidoc

* Add tests around limitations

* Add comment

* Convert some files to typescript

* Move Boom.boomify to where the errors are created

* Use Boom.badRequest for now

* Fix lint issue

* Move files

* Update tests

* Add functional test

* Export all documents by default

* Update test assertions

* Use ~10000 saved objects in export api integration test

* Convert route to typescript, add content-type response header

* Move some tests to api_integration

* Use new sort and rename functions/variables

* Move tests to API integration

* Cleanup and finalize api integration tests

* Make type or objects required but not both in the same call

* Add spaces / security tests

* Add noTypeOrObjects to security / spaces tests

* Use json-stable-stringify and add tests for export ordering

* Address self feedback, add without kibana index test

* Only allow export API to export index-pattern, dashboard, visualization and search type objects

* Make import export size configurable and fix broken tests

* Fix broken tests

* Move test config to mock server

* Add more typescript types instead of using any

* Convert request from GET to POST

* Fix saved objects mixin test

* Update src/legacy/server/saved_objects/lib/export.ts

Co-Authored-By: mikecote <mikecote@users.noreply.github.com>

* Apply PR feedback

* Fix lint error

* Update test snapshots due to jest upgrade

* Add error handling for bulkGet

* Split export API into two endpoints

* Update src/legacy/server/saved_objects/routes/export_by_type.test.ts

Co-Authored-By: mikecote <mikecote@users.noreply.github.com>

* Update docs/api/saved-objects/export_by_type.asciidoc

Co-Authored-By: mikecote <mikecote@users.noreply.github.com>

* Update docs/api/saved-objects/export_by_type.asciidoc

Co-Authored-By: mikecote <mikecote@users.noreply.github.com>

* Update src/legacy/server/saved_objects/routes/export_objects.test.ts

Co-Authored-By: mikecote <mikecote@users.noreply.github.com>

* Apply PR feedback

* MockServer -> createMockServer

* Revert back to single API

* Re-apply PR feedback
This commit is contained in:
Mike Côté 2019-03-05 16:38:23 -05:00 committed by GitHub
parent 01a0ea6731
commit d54690e064
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 2218 additions and 25 deletions

View file

@ -18,6 +18,7 @@ NOTE: You cannot access these endpoints via the Console in Kibana.
* <<saved-objects-api-bulk-create>>
* <<saved-objects-api-update>>
* <<saved-objects-api-delete>>
* <<saved-objects-api-export>>
include::saved-objects/get.asciidoc[]
include::saved-objects/bulk_get.asciidoc[]
@ -26,3 +27,4 @@ include::saved-objects/create.asciidoc[]
include::saved-objects/bulk_create.asciidoc[]
include::saved-objects/update.asciidoc[]
include::saved-objects/delete.asciidoc[]
include::saved-objects/export.asciidoc[]

View file

@ -0,0 +1,39 @@
[[saved-objects-api-export]]
=== Export Objects
experimental[This functionality is *experimental* and may be changed or removed completely in a future release.]
The export saved objects API enables you to retrieve a set of saved objects that can later be imported into Kibana.
Note: You cannot access this endpoint via the Console in Kibana.
==== Request
`POST /api/saved_objects/_export`
==== Request Body
`type` (optional)::
(array|string) The saved object type(s) that the export should be limited to
`objects` (optional)::
(array) A list of objects to export
Note: At least `type` or `objects` must be passed in.
==== Response body
The response body will have a format of newline delimited JSON.
==== Examples
The following example exports all index pattern saved objects.
[source,js]
--------------------------------------------------
POST api/saved_objects/_export
{
"type": "index-patterns"
}
--------------------------------------------------
// KIBANA
A successful call returns a response code of `200` along with the exported objects as the response body.

View file

@ -108,6 +108,7 @@
"@kbn/pm": "1.0.0",
"@kbn/test-subj-selector": "0.2.1",
"@kbn/ui-framework": "1.0.0",
"@types/json-stable-stringify": "^1.0.32",
"@types/lodash.clonedeep": "^4.5.4",
"JSONStream": "1.1.1",
"abortcontroller-polyfill": "^1.1.9",
@ -161,6 +162,7 @@
"joi": "^13.5.2",
"jquery": "^3.3.1",
"js-yaml": "3.4.1",
"json-stable-stringify": "^1.0.1",
"json-stringify-pretty-compact": "1.0.4",
"json-stringify-safe": "5.0.1",
"leaflet": "1.0.3",
@ -364,7 +366,6 @@
"jest-cli": "^24.1.0",
"jest-raw-loader": "^1.0.1",
"jimp": "0.2.28",
"json-stable-stringify": "^1.0.1",
"json5": "^1.0.1",
"karma": "3.1.4",
"karma-chrome-launcher": "2.1.1",

View file

@ -251,4 +251,8 @@ export default () => Joi.object({
locale: Joi.string().default('en'),
}).default(),
savedObjects: Joi.object({
maxImportExportSize: Joi.number().default(10000),
}).default(),
}).default();

View file

@ -0,0 +1,521 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { getSortedObjectsForExport, sortObjects } from './export';
describe('getSortedObjectsForExport()', () => {
const savedObjectsClient = {
errors: {} as any,
find: jest.fn(),
bulkGet: jest.fn(),
create: jest.fn(),
bulkCreate: jest.fn(),
delete: jest.fn(),
get: jest.fn(),
update: jest.fn(),
};
afterEach(() => {
savedObjectsClient.find.mockReset();
savedObjectsClient.bulkGet.mockReset();
savedObjectsClient.create.mockReset();
savedObjectsClient.bulkCreate.mockReset();
savedObjectsClient.delete.mockReset();
savedObjectsClient.get.mockReset();
savedObjectsClient.update.mockReset();
});
test('exports selected types and sorts them', async () => {
savedObjectsClient.find.mockResolvedValueOnce({
total: 2,
saved_objects: [
{
id: '2',
type: 'search',
references: [
{
type: 'index-pattern',
id: '1',
},
],
},
{
id: '1',
type: 'index-pattern',
references: [],
},
],
});
const response = await getSortedObjectsForExport({
savedObjectsClient,
exportSizeLimit: 500,
types: ['index-pattern', 'search'],
});
expect(response).toMatchInlineSnapshot(`
Array [
Object {
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"id": "2",
"references": Array [
Object {
"id": "1",
"type": "index-pattern",
},
],
"type": "search",
},
]
`);
expect(savedObjectsClient.find).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
Array [
Object {
"perPage": 500,
"sortField": "_id",
"sortOrder": "asc",
"type": Array [
"index-pattern",
"search",
],
},
],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
],
}
`);
});
test('export selected types throws error when exceeding exportSizeLimit', async () => {
savedObjectsClient.find.mockResolvedValueOnce({
total: 2,
saved_objects: [
{
id: '2',
type: 'search',
references: [
{
type: 'index-pattern',
id: '1',
},
],
},
{
id: '1',
type: 'index-pattern',
references: [],
},
],
});
await expect(
getSortedObjectsForExport({
savedObjectsClient,
exportSizeLimit: 1,
types: ['index-pattern', 'search'],
})
).rejects.toThrowErrorMatchingInlineSnapshot(`"Can't export more than 1 objects"`);
});
test('exports selected objects and sorts them', async () => {
savedObjectsClient.bulkGet.mockResolvedValueOnce({
saved_objects: [
{
id: '2',
type: 'search',
references: [
{
type: 'index-pattern',
id: '1',
},
],
},
{
id: '1',
type: 'index-pattern',
references: [],
},
],
});
const response = await getSortedObjectsForExport({
exportSizeLimit: 10000,
savedObjectsClient,
types: ['index-pattern', 'search'],
objects: [
{
type: 'index-pattern',
id: '1',
},
{
type: 'search',
id: '2',
},
],
});
expect(response).toMatchInlineSnapshot(`
Array [
Object {
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"id": "2",
"references": Array [
Object {
"id": "1",
"type": "index-pattern",
},
],
"type": "search",
},
]
`);
expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
Array [
Array [
Object {
"id": "1",
"type": "index-pattern",
},
Object {
"id": "2",
"type": "search",
},
],
],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
],
}
`);
});
test('export selected objects throws error when exceeding exportSizeLimit', async () => {
const exportOpts = {
exportSizeLimit: 1,
savedObjectsClient,
types: ['index-pattern', 'search'],
objects: [
{
type: 'index-pattern',
id: '1',
},
{
type: 'search',
id: '2',
},
],
};
await expect(getSortedObjectsForExport(exportOpts)).rejects.toThrowErrorMatchingInlineSnapshot(
`"Can't export more than 1 objects"`
);
});
});
describe('sortObjects()', () => {
test('should return on empty array', () => {
expect(sortObjects([])).toEqual([]);
});
test('should not change sorted array', () => {
const docs = [
{
id: '1',
type: 'index-pattern',
attributes: {},
references: [],
},
{
id: '2',
type: 'search',
attributes: {},
references: [
{
name: 'ref1',
type: 'index-pattern',
id: '1',
},
],
},
];
expect(sortObjects(docs)).toMatchInlineSnapshot(`
Array [
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "ref1",
"type": "index-pattern",
},
],
"type": "search",
},
]
`);
});
test('should not mutate parameter', () => {
const docs = [
{
id: '2',
type: 'search',
attributes: {},
references: [
{
name: 'ref1',
type: 'index-pattern',
id: '1',
},
],
},
{
id: '1',
type: 'index-pattern',
attributes: {},
references: [],
},
];
expect(sortObjects(docs)).toMatchInlineSnapshot(`
Array [
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "ref1",
"type": "index-pattern",
},
],
"type": "search",
},
]
`);
expect(docs).toMatchInlineSnapshot(`
Array [
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "ref1",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
]
`);
});
test('should sort unordered array', () => {
const docs = [
{
id: '5',
type: 'dashboard',
attributes: {},
references: [
{
name: 'ref1',
type: 'visualization',
id: '3',
},
{
name: 'ref2',
type: 'visualization',
id: '4',
},
],
},
{
id: '4',
type: 'visualization',
attributes: {},
references: [
{
name: 'ref1',
type: 'index-pattern',
id: '1',
},
],
},
{
id: '3',
type: 'visualization',
attributes: {},
references: [
{
name: 'ref1',
type: 'search',
id: '2',
},
],
},
{
id: '2',
type: 'search',
attributes: {},
references: [
{
name: 'ref1',
type: 'index-pattern',
id: '1',
},
],
},
{
id: '1',
type: 'index-pattern',
attributes: {},
references: [],
},
];
expect(sortObjects(docs)).toMatchInlineSnapshot(`
Array [
Object {
"attributes": Object {},
"id": "1",
"references": Array [],
"type": "index-pattern",
},
Object {
"attributes": Object {},
"id": "2",
"references": Array [
Object {
"id": "1",
"name": "ref1",
"type": "index-pattern",
},
],
"type": "search",
},
Object {
"attributes": Object {},
"id": "3",
"references": Array [
Object {
"id": "2",
"name": "ref1",
"type": "search",
},
],
"type": "visualization",
},
Object {
"attributes": Object {},
"id": "4",
"references": Array [
Object {
"id": "1",
"name": "ref1",
"type": "index-pattern",
},
],
"type": "visualization",
},
Object {
"attributes": Object {},
"id": "5",
"references": Array [
Object {
"id": "3",
"name": "ref1",
"type": "visualization",
},
Object {
"id": "4",
"name": "ref2",
"type": "visualization",
},
],
"type": "dashboard",
},
]
`);
});
test('detects circular dependencies', () => {
const docs = [
{
id: '1',
type: 'foo',
attributes: {},
references: [
{
name: 'ref1',
type: 'foo',
id: '2',
},
],
},
{
id: '2',
type: 'foo',
attributes: {},
references: [
{
name: 'ref1',
type: 'foo',
id: '1',
},
],
},
];
expect(() => sortObjects(docs)).toThrowErrorMatchingInlineSnapshot(
`"circular reference: [foo:1] ref-> [foo:2] ref-> [foo:1]"`
);
});
});

View file

@ -0,0 +1,103 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Boom from 'boom';
import { SavedObject, SavedObjectsClient } from '../service/saved_objects_client';
interface ObjectToExport {
id: string;
type: string;
}
interface ExportObjectsOptions {
types?: string[];
objects?: ObjectToExport[];
savedObjectsClient: SavedObjectsClient;
exportSizeLimit: number;
}
export async function getSortedObjectsForExport({
types,
objects,
savedObjectsClient,
exportSizeLimit,
}: ExportObjectsOptions) {
let objectsToExport: SavedObject[] = [];
if (objects) {
if (objects.length > exportSizeLimit) {
throw Boom.badRequest(`Can't export more than ${exportSizeLimit} objects`);
}
({ saved_objects: objectsToExport } = await savedObjectsClient.bulkGet(objects));
const erroredObjects = objectsToExport.filter(obj => !!obj.error);
if (erroredObjects.length) {
const err = Boom.badRequest();
err.output.payload.attributes = {
objects: erroredObjects,
};
throw err;
}
} else {
const findResponse = await savedObjectsClient.find({
type: types,
sortField: '_id',
sortOrder: 'asc',
perPage: exportSizeLimit,
});
if (findResponse.total > exportSizeLimit) {
throw Boom.badRequest(`Can't export more than ${exportSizeLimit} objects`);
}
({ saved_objects: objectsToExport } = findResponse);
}
return sortObjects(objectsToExport);
}
export function sortObjects(savedObjects: SavedObject[]) {
const path = new Set();
const sorted = new Set();
const objectsByTypeId = new Map(
savedObjects.map(object => [`${object.type}:${object.id}`, object] as [string, SavedObject])
);
function includeObjects(objects: SavedObject[]) {
for (const object of objects) {
if (path.has(object)) {
throw Boom.badRequest(
`circular reference: ${[...path, object]
.map(obj => `[${obj.type}:${obj.id}]`)
.join(' ref-> ')}`
);
}
const refdObjects = object.references
.map(ref => objectsByTypeId.get(`${ref.type}:${ref.id}`))
.filter((ref): ref is SavedObject => !!ref);
if (refdObjects.length) {
path.add(object);
includeObjects(refdObjects);
path.delete(object);
}
sorted.add(object);
}
}
includeObjects(savedObjects);
return [...sorted];
}

View file

@ -0,0 +1,22 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Hapi from 'hapi';
export function MockServer(config?: { [key: string]: any }): Hapi.Server;

View file

@ -22,6 +22,7 @@ import { defaultValidationErrorHandler } from '../../../../core/server/http/http
const defaultConfig = {
'kibana.index': '.kibana',
'savedObjects.maxImportExportSize': 10000,
};
export function createMockServer(config: { [key: string]: any } = defaultConfig) {

View file

@ -0,0 +1,118 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Hapi from 'hapi';
import { createMockServer } from './_mock_server';
import { createExportRoute } from './export';
describe('POST /api/saved_objects/_export', () => {
let server: Hapi.Server;
const savedObjectsClient = {
errors: {} as any,
bulkCreate: jest.fn(),
bulkGet: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
find: jest.fn(),
get: jest.fn(),
update: jest.fn(),
};
beforeEach(() => {
server = createMockServer();
const prereqs = {
getSavedObjectsClient: {
assign: 'savedObjectsClient',
method() {
return savedObjectsClient;
},
},
};
server.route(createExportRoute(prereqs, server));
});
afterEach(() => {
savedObjectsClient.bulkCreate.mockReset();
savedObjectsClient.bulkGet.mockReset();
savedObjectsClient.create.mockReset();
savedObjectsClient.delete.mockReset();
savedObjectsClient.find.mockReset();
savedObjectsClient.get.mockReset();
savedObjectsClient.update.mockReset();
});
test('formats successful response', async () => {
const request = {
method: 'POST',
url: '/api/saved_objects/_export',
payload: {
type: 'index-pattern',
},
};
savedObjectsClient.find.mockResolvedValueOnce({
total: 1,
saved_objects: [
{
id: '1',
type: 'index-pattern',
references: [],
},
],
});
const { payload, statusCode, headers } = await server.inject(request);
const objects = payload.split('\n').map(row => JSON.parse(row));
expect(statusCode).toBe(200);
expect(headers).toHaveProperty('content-disposition', 'attachment; filename="export.ndjson"');
expect(headers).toHaveProperty('content-type', 'application/ndjson');
expect(objects).toMatchInlineSnapshot(`
Array [
Object {
"id": "1",
"references": Array [],
"type": "index-pattern",
},
]
`);
expect(savedObjectsClient.find).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
Array [
Object {
"perPage": 10000,
"sortField": "_id",
"sortOrder": "asc",
"type": Array [
"index-pattern",
],
},
],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
],
}
`);
});
});

View file

@ -0,0 +1,81 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Hapi from 'hapi';
import Joi from 'joi';
import stringify from 'json-stable-stringify';
import { SavedObjectsClient } from '../';
import { getSortedObjectsForExport } from '../lib/export';
import { Prerequisites } from './types';
const ALLOWED_TYPES = ['index-pattern', 'search', 'visualization', 'dashboard'];
interface ExportRequest extends Hapi.Request {
pre: {
savedObjectsClient: SavedObjectsClient;
};
payload: {
type?: string[];
objects?: Array<{
type: string;
id: string;
}>;
};
}
export const createExportRoute = (prereqs: Prerequisites, server: Hapi.Server) => ({
path: '/api/saved_objects/_export',
method: 'POST',
config: {
pre: [prereqs.getSavedObjectsClient],
validate: {
payload: Joi.object()
.keys({
type: Joi.array()
.items(Joi.string().valid(ALLOWED_TYPES))
.single()
.optional(),
objects: Joi.array()
.items({
type: Joi.string()
.valid(ALLOWED_TYPES)
.required(),
id: Joi.string().required(),
})
.max(server.config().get('savedObjects.maxImportExportSize'))
.optional(),
})
.xor('type', 'objects')
.default(),
},
async handler(request: ExportRequest, h: Hapi.ResponseToolkit) {
const { savedObjectsClient } = request.pre;
const docsToExport = await getSortedObjectsForExport({
savedObjectsClient,
types: request.payload.type,
objects: request.payload.objects,
exportSizeLimit: server.config().get('savedObjects.maxImportExportSize'),
});
return h
.response(docsToExport.map(doc => stringify(doc)).join('\n'))
.header('Content-Disposition', `attachment; filename="export.ndjson"`)
.header('Content-Type', 'application/ndjson');
},
},
});

View file

@ -24,3 +24,4 @@ export { createDeleteRoute } from './delete';
export { createFindRoute } from './find';
export { createGetRoute } from './get';
export { createUpdateRoute } from './update';
export { createExportRoute } from './export';

View file

@ -31,6 +31,7 @@ import {
createFindRoute,
createGetRoute,
createUpdateRoute,
createExportRoute,
} from './routes';
export function savedObjectsMixin(kbnServer, server) {
@ -61,6 +62,7 @@ export function savedObjectsMixin(kbnServer, server) {
server.route(createFindRoute(prereqs));
server.route(createGetRoute(prereqs));
server.route(createUpdateRoute(prereqs));
server.route(createExportRoute(prereqs, server));
const schema = new SavedObjectsSchema(kbnServer.uiExports.savedObjectSchemas);
const serializer = new SavedObjectsSerializer(schema);

View file

@ -24,7 +24,13 @@ describe('Saved Objects Mixin', () => {
let mockServer;
const mockCallCluster = jest.fn();
const stubCallCluster = jest.fn();
const stubConfig = jest.fn();
const config = {
'kibana.index': 'kibana.index',
'savedObjects.maxImportExportSize': 10000,
};
const stubConfig = jest.fn((key) => {
return config[key];
});
beforeEach(() => {
mockServer = {
@ -96,9 +102,9 @@ describe('Saved Objects Mixin', () => {
});
describe('Routes', () => {
it('should create 7 routes', () => {
it('should create 8 routes', () => {
savedObjectsMixin(mockKbnServer, mockServer);
expect(mockServer.route).toHaveBeenCalledTimes(7);
expect(mockServer.route).toHaveBeenCalledTimes(8);
});
it('should add POST /api/saved_objects/_bulk_create', () => {
savedObjectsMixin(mockKbnServer, mockServer);
@ -128,6 +134,10 @@ describe('Saved Objects Mixin', () => {
savedObjectsMixin(mockKbnServer, mockServer);
expect(mockServer.route).toHaveBeenCalledWith(expect.objectContaining({ path: '/api/saved_objects/{type}/{id}', method: 'PUT' }));
});
it('should add GET /api/saved_objects/_export', () => {
savedObjectsMixin(mockKbnServer, mockServer);
expect(mockServer.route).toHaveBeenCalledWith(expect.objectContaining({ path: '/api/saved_objects/_export', method: 'POST' }));
});
});
describe('Saved object service', () => {
@ -211,7 +221,6 @@ describe('Saved Objects Mixin', () => {
return [];
}
});
stubConfig.mockImplementation(() => 'kibana-index');
const client = await service.getScopedSavedObjectsClient();
await client.create('testtype');
expect(stubCallCluster).toHaveBeenCalled();

View file

@ -18,36 +18,44 @@
*/
import Boom from 'boom';
import { getProperty } from '../../../../mappings';
const TOP_LEVEL_FIELDS = ['_id'];
export function getSortingParams(mappings, type, sortField, sortOrder) {
if (!sortField) {
return {};
}
let typeField = type;
const types = [].concat(type);
if (Array.isArray(type)) {
if (type.length === 1) {
typeField = type[0];
} else {
const rootField = getProperty(mappings, sortField);
if (!rootField) {
throw Boom.badRequest(`Unable to sort multiple types by field ${sortField}, not a root property`);
}
return {
sort: [{
[sortField]: {
order: sortOrder,
unmapped_type: rootField.type
}
}]
};
}
if (TOP_LEVEL_FIELDS.includes(sortField)) {
return {
sort: [{
[sortField]: {
order: sortOrder,
},
}],
};
}
if (types.length > 1) {
const rootField = getProperty(mappings, sortField);
if (!rootField) {
throw Boom.badRequest(`Unable to sort multiple types by field ${sortField}, not a root property`);
}
return {
sort: [{
[sortField]: {
order: sortOrder,
unmapped_type: rootField.type
}
}]
};
}
const [typeField] = types;
const key = `${typeField}.${sortField}`;
const field = getProperty(mappings, key);
if (!field) {

View file

@ -0,0 +1,396 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import expect from 'expect.js';
export default function ({ getService }) {
const supertest = getService('supertest');
const es = getService('es');
const esArchiver = getService('esArchiver');
describe('export', () => {
describe('with kibana index', () => {
describe('basic amount of saved objects', () => {
before(() => esArchiver.load('saved_objects/basic'));
after(() => esArchiver.unload('saved_objects/basic'));
it('should return objects in dependency order', async () => {
await supertest
.post('/api/saved_objects/_export')
.send({
type: ['index-pattern', 'search', 'visualization', 'dashboard'],
})
.expect(200)
.then((resp) => {
const objects = resp.text.split('\n').map(JSON.parse);
expect(objects).to.have.length(3);
expect(objects[0]).to.have.property('id', '91200a00-9efd-11e7-acb3-3dab96693fab');
expect(objects[0]).to.have.property('type', 'index-pattern');
expect(objects[1]).to.have.property('id', 'dd7caf20-9efd-11e7-acb3-3dab96693fab');
expect(objects[1]).to.have.property('type', 'visualization');
expect(objects[2]).to.have.property('id', 'be3733a0-9efe-11e7-acb3-3dab96693fab');
expect(objects[2]).to.have.property('type', 'dashboard');
});
});
it('should validate types', async () => {
await supertest
.post('/api/saved_objects/_export')
.send({
type: ['foo'],
})
.expect(400)
.then((resp) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
// eslint-disable-next-line max-len
message: 'child "type" fails because ["type" at position 0 fails because ["0" must be one of [index-pattern, search, visualization, dashboard]]]',
validation: { source: 'payload', keys: [ 'type.0' ] },
});
});
});
it('should validate types in objects', async () => {
await supertest
.post('/api/saved_objects/_export')
.send({
objects: [
{
type: 'foo',
id: '1',
},
],
})
.expect(400)
.then((resp) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
// eslint-disable-next-line max-len
message: 'child "objects" fails because ["objects" at position 0 fails because [child "type" fails because ["type" must be one of [index-pattern, search, visualization, dashboard]]]]',
validation: { source: 'payload', keys: [ 'objects.0.type' ] },
});
});
});
it(`should throw error when object doesn't exist`, async () => {
await supertest
.post('/api/saved_objects/_export')
.send({
objects: [
{
type: 'dashboard',
id: '1',
},
],
})
.expect(400)
.then((resp) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message: 'Bad Request',
attributes: {
objects: [
{
id: '1',
type: 'dashboard',
error: {
statusCode: 404,
message: 'Not found',
},
},
],
},
});
});
});
});
describe('10,000 objects', () => {
before(() => esArchiver.load('saved_objects/10k'));
after(() => esArchiver.unload('saved_objects/10k'));
it('should return 400 when exporting without type or objects passed in', async () => {
await supertest
.post('/api/saved_objects/_export')
.expect(400)
.then((resp) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message: '"value" must be an object',
validation: { source: 'payload', keys: [ 'value' ] },
});
});
});
it('should return 200 when exporting by single type', async () => {
await supertest
.post('/api/saved_objects/_export')
.send({
type: 'dashboard',
})
.expect(200)
.then((resp) => {
expect(resp.headers['content-disposition']).to.eql('attachment; filename="export.ndjson"');
expect(resp.headers['content-type']).to.eql('application/ndjson');
const objects = resp.text.split('\n').map(JSON.parse);
expect(objects).to.eql([{
attributes: {
description: '',
hits: 0,
kibanaSavedObjectMeta: {
searchSourceJSON: objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON,
},
optionsJSON: objects[0].attributes.optionsJSON,
panelsJSON: objects[0].attributes.panelsJSON,
refreshInterval: {
display: 'Off',
pause: false,
value: 0,
},
timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700',
timeRestore: true,
timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700',
title: 'Requests',
uiStateJSON: '{}',
version: 1,
},
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
migrationVersion: {
dashboard: '7.0.0',
},
references: [
{
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
name: 'panel_0',
type: 'visualization',
},
],
type: 'dashboard',
updated_at: '2017-09-21T18:57:40.826Z',
version: objects[0].version,
}]);
expect(() => JSON.parse(objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON)).not.to.throwError();
expect(() => JSON.parse(objects[0].attributes.optionsJSON)).not.to.throwError();
expect(() => JSON.parse(objects[0].attributes.panelsJSON)).not.to.throwError();
});
});
it('should return 200 when exporting by array type', async () => {
await supertest
.post('/api/saved_objects/_export')
.send({
type: ['dashboard'],
})
.expect(200)
.then((resp) => {
expect(resp.headers['content-disposition']).to.eql('attachment; filename="export.ndjson"');
expect(resp.headers['content-type']).to.eql('application/ndjson');
const objects = resp.text.split('\n').map(JSON.parse);
expect(objects).to.eql([{
attributes: {
description: '',
hits: 0,
kibanaSavedObjectMeta: {
searchSourceJSON: objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON,
},
optionsJSON: objects[0].attributes.optionsJSON,
panelsJSON: objects[0].attributes.panelsJSON,
refreshInterval: {
display: 'Off',
pause: false,
value: 0,
},
timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700',
timeRestore: true,
timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700',
title: 'Requests',
uiStateJSON: '{}',
version: 1,
},
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
migrationVersion: {
dashboard: '7.0.0',
},
references: [
{
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
name: 'panel_0',
type: 'visualization',
},
],
type: 'dashboard',
updated_at: '2017-09-21T18:57:40.826Z',
version: objects[0].version,
}]);
expect(() => JSON.parse(objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON)).not.to.throwError();
expect(() => JSON.parse(objects[0].attributes.optionsJSON)).not.to.throwError();
expect(() => JSON.parse(objects[0].attributes.panelsJSON)).not.to.throwError();
});
});
it('should return 200 when exporting by objects', async () => {
await supertest
.post('/api/saved_objects/_export')
.send({
objects: [
{
type: 'dashboard',
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
},
],
})
.expect(200)
.then((resp) => {
expect(resp.headers['content-disposition']).to.eql('attachment; filename="export.ndjson"');
expect(resp.headers['content-type']).to.eql('application/ndjson');
const objects = resp.text.split('\n').map(JSON.parse);
expect(objects).to.eql([{
attributes: {
description: '',
hits: 0,
kibanaSavedObjectMeta: {
searchSourceJSON: objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON,
},
optionsJSON: objects[0].attributes.optionsJSON,
panelsJSON: objects[0].attributes.panelsJSON,
refreshInterval: {
display: 'Off',
pause: false,
value: 0,
},
timeFrom: 'Wed Sep 16 2015 22:52:17 GMT-0700',
timeRestore: true,
timeTo: 'Fri Sep 18 2015 12:24:38 GMT-0700',
title: 'Requests',
uiStateJSON: '{}',
version: 1,
},
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
migrationVersion: {
dashboard: '7.0.0',
},
references: [
{
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
name: 'panel_0',
type: 'visualization',
},
],
type: 'dashboard',
updated_at: '2017-09-21T18:57:40.826Z',
version: objects[0].version,
}]);
expect(() => JSON.parse(objects[0].attributes.kibanaSavedObjectMeta.searchSourceJSON)).not.to.throwError();
expect(() => JSON.parse(objects[0].attributes.optionsJSON)).not.to.throwError();
expect(() => JSON.parse(objects[0].attributes.panelsJSON)).not.to.throwError();
});
});
it('should return 400 when exporting by type and objects', async () => {
await supertest
.post('/api/saved_objects/_export')
.send({
type: 'dashboard',
objects: [
{
type: 'dashboard',
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
},
],
})
.expect(400)
.then((resp) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message: '"value" contains a conflict between exclusive peers [type, objects]',
validation: { source: 'payload', keys: [ 'value' ] },
});
});
});
});
describe('10,001 objects', () => {
let customVisId;
before(async () => {
await esArchiver.load('saved_objects/10k');
await supertest
.post('/api/saved_objects/visualization')
.send({
attributes: {
title: 'My favorite vis',
},
})
.expect(200)
.then((resp) => {
customVisId = resp.body.id;
});
});
after(async () => {
await supertest
.delete(`/api/saved_objects/visualization/${customVisId}`)
.expect(200);
await esArchiver.unload('saved_objects/10k');
});
it('should return 400 when exporting more than 10,000', async () => {
await supertest
.post('/api/saved_objects/_export')
.send({
type: ['dashboard', 'visualization', 'search', 'index-pattern'],
})
.expect(400)
.then((resp) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message: `Can't export more than 10000 objects`
});
});
});
});
});
describe('without kibana index', () => {
before(async () => (
// just in case the kibana server has recreated it
await es.indices.delete({
index: '.kibana',
ignore: [404],
})
));
it('should return empty response', async () => {
await supertest
.post('/api/saved_objects/_export')
.send({
type: ['index-pattern', 'search', 'visualization', 'dashboard'],
})
.expect(200)
.then((resp) => {
expect(resp.text).to.eql('');
});
});
});
});
}

View file

@ -23,6 +23,7 @@ export default function ({ loadTestFile }) {
loadTestFile(require.resolve('./bulk_get'));
loadTestFile(require.resolve('./create'));
loadTestFile(require.resolve('./delete'));
loadTestFile(require.resolve('./export'));
loadTestFile(require.resolve('./find'));
loadTestFile(require.resolve('./get'));
loadTestFile(require.resolve('./update'));

View file

@ -0,0 +1,253 @@
{
"type": "index",
"value": {
"index": ".kibana",
"settings": {
"index": {
"number_of_shards": "1",
"number_of_replicas": "1"
}
},
"mappings": {
"dynamic": "strict",
"properties": {
"config": {
"dynamic": "true",
"properties": {
"buildNum": {
"type": "keyword"
},
"defaultIndex": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
},
"dashboard": {
"properties": {
"description": {
"type": "text"
},
"hits": {
"type": "integer"
},
"kibanaSavedObjectMeta": {
"properties": {
"searchSourceJSON": {
"type": "text"
}
}
},
"optionsJSON": {
"type": "text"
},
"panelsJSON": {
"type": "text"
},
"refreshInterval": {
"properties": {
"display": {
"type": "keyword"
},
"pause": {
"type": "boolean"
},
"section": {
"type": "integer"
},
"value": {
"type": "integer"
}
}
},
"timeFrom": {
"type": "keyword"
},
"timeRestore": {
"type": "boolean"
},
"timeTo": {
"type": "keyword"
},
"title": {
"type": "text"
},
"uiStateJSON": {
"type": "text"
},
"version": {
"type": "integer"
}
}
},
"index-pattern": {
"properties": {
"fieldFormatMap": {
"type": "text"
},
"fields": {
"type": "text"
},
"intervalName": {
"type": "keyword"
},
"notExpandable": {
"type": "boolean"
},
"sourceFilters": {
"type": "text"
},
"timeFieldName": {
"type": "keyword"
},
"title": {
"type": "text"
}
}
},
"search": {
"properties": {
"columns": {
"type": "keyword"
},
"description": {
"type": "text"
},
"hits": {
"type": "integer"
},
"kibanaSavedObjectMeta": {
"properties": {
"searchSourceJSON": {
"type": "text"
}
}
},
"sort": {
"type": "keyword"
},
"title": {
"type": "text"
},
"version": {
"type": "integer"
}
}
},
"server": {
"properties": {
"uuid": {
"type": "keyword"
}
}
},
"timelion-sheet": {
"properties": {
"description": {
"type": "text"
},
"hits": {
"type": "integer"
},
"kibanaSavedObjectMeta": {
"properties": {
"searchSourceJSON": {
"type": "text"
}
}
},
"timelion_chart_height": {
"type": "integer"
},
"timelion_columns": {
"type": "integer"
},
"timelion_interval": {
"type": "keyword"
},
"timelion_other_interval": {
"type": "keyword"
},
"timelion_rows": {
"type": "integer"
},
"timelion_sheet": {
"type": "text"
},
"title": {
"type": "text"
},
"version": {
"type": "integer"
}
}
},
"namespace": {
"type": "keyword"
},
"type": {
"type": "keyword"
},
"updated_at": {
"type": "date"
},
"url": {
"properties": {
"accessCount": {
"type": "long"
},
"accessDate": {
"type": "date"
},
"createDate": {
"type": "date"
},
"url": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 2048
}
}
}
}
},
"visualization": {
"properties": {
"description": {
"type": "text"
},
"kibanaSavedObjectMeta": {
"properties": {
"searchSourceJSON": {
"type": "text"
}
}
},
"savedSearchId": {
"type": "keyword"
},
"title": {
"type": "text"
},
"uiStateJSON": {
"type": "text"
},
"version": {
"type": "integer"
},
"visState": {
"type": "text"
}
}
}
}
}
}
}

View file

@ -0,0 +1,144 @@
/*
* 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 'expect.js';
import { SuperTest } from 'supertest';
import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants';
import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils';
import { DescribeFn, TestDefinitionAuthentication } from '../lib/types';
interface ExportTest {
statusCode: number;
description: string;
response: (resp: { [key: string]: any }) => void;
}
interface ExportTests {
spaceAwareType: ExportTest;
noTypeOrObjects: ExportTest;
}
interface ExportTestDefinition {
user?: TestDefinitionAuthentication;
spaceId?: string;
tests: ExportTests;
}
export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>) {
const createExpectRbacForbidden = (type: string) => (resp: { [key: string]: any }) => {
// In export only, the API uses "bulk_get" or "find" depending on the parameters it receives.
// The best that could be done here is to have an if statement to ensure at least one of the
// two errors has been thrown.
if (resp.body.message.indexOf(`bulk_get`) !== -1) {
expect(resp.body).to.eql({
statusCode: 403,
error: 'Forbidden',
message: `Unable to bulk_get ${type}, missing action:saved_objects/${type}/bulk_get`,
});
return;
}
expect(resp.body).to.eql({
statusCode: 403,
error: 'Forbidden',
message: `Unable to find ${type}, missing action:saved_objects/${type}/find`,
});
};
const expectTypeOrObjectsRequired = (resp: { [key: string]: any }) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message: '"value" must be an object',
validation: { source: 'payload', keys: ['value'] },
});
};
const createExpectVisualizationResults = (spaceId = DEFAULT_SPACE_ID) => (resp: {
[key: string]: any;
}) => {
const response = JSON.parse(resp.text);
expect(response).to.eql({
type: 'visualization',
id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`,
version: response.version,
attributes: response.attributes,
references: [
{
name: 'kibanaSavedObjectMeta.searchSourceJSON.index',
type: 'index-pattern',
id: `${getIdPrefix(spaceId)}91200a00-9efd-11e7-acb3-3dab96693fab`,
},
],
migrationVersion: { visualization: '7.0.0' },
updated_at: '2017-09-21T18:51:23.794Z',
});
};
const makeExportTest = (describeFn: DescribeFn) => (
description: string,
definition: ExportTestDefinition
) => {
const { user = {}, spaceId = DEFAULT_SPACE_ID, tests } = definition;
describeFn(description, () => {
before(() => esArchiver.load('saved_objects/spaces'));
after(() => esArchiver.unload('saved_objects/spaces'));
it(`space aware type should return ${tests.spaceAwareType.statusCode} with ${
tests.spaceAwareType.description
} when querying by type`, async () => {
await supertest
.post(`${getUrlPrefix(spaceId)}/api/saved_objects/_export`)
.send({
type: 'visualization',
})
.auth(user.username, user.password)
.expect(tests.spaceAwareType.statusCode)
.then(tests.spaceAwareType.response);
});
it(`space aware type should return ${tests.spaceAwareType.statusCode} with ${
tests.spaceAwareType.description
} when querying by objects`, async () => {
await supertest
.post(`${getUrlPrefix(spaceId)}/api/saved_objects/_export`)
.send({
objects: [
{
type: 'visualization',
id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`,
},
],
})
.auth(user.username, user.password)
.expect(tests.spaceAwareType.statusCode)
.then(tests.spaceAwareType.response);
});
describe('no type or objects', () => {
it(`should return ${tests.noTypeOrObjects.statusCode} with ${
tests.noTypeOrObjects.description
}`, async () => {
await supertest
.post(`${getUrlPrefix(spaceId)}/api/saved_objects/_export`)
.auth(user.username, user.password)
.expect(tests.noTypeOrObjects.statusCode)
.then(tests.noTypeOrObjects.response);
});
});
});
};
const exportTest = makeExportTest(describe);
// @ts-ignore
exportTest.only = makeExportTest(describe.only);
return {
createExpectRbacForbidden,
expectTypeOrObjectsRequired,
createExpectVisualizationResults,
exportTest,
};
}

View file

@ -0,0 +1,229 @@
/*
* 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 { AUTHENTICATION } from '../../common/lib/authentication';
import { SPACES } from '../../common/lib/spaces';
import { TestInvoker } from '../../common/lib/types';
import { exportTestSuiteFactory } from '../../common/suites/export';
// tslint:disable:no-default-export
export default function({ getService }: TestInvoker) {
const supertest = getService('supertestWithoutAuth');
const esArchiver = getService('esArchiver');
describe('export', () => {
const {
createExpectRbacForbidden,
expectTypeOrObjectsRequired,
createExpectVisualizationResults,
exportTest,
} = exportTestSuiteFactory(esArchiver, supertest);
[
{
spaceId: SPACES.DEFAULT.spaceId,
users: {
noAccess: AUTHENTICATION.NOT_A_KIBANA_USER,
superuser: AUTHENTICATION.SUPERUSER,
legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER,
allGlobally: AUTHENTICATION.KIBANA_RBAC_USER,
readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER,
dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER,
dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER,
allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER,
readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER,
allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER,
},
},
{
spaceId: SPACES.SPACE_1.spaceId,
users: {
noAccess: AUTHENTICATION.NOT_A_KIBANA_USER,
superuser: AUTHENTICATION.SUPERUSER,
legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER,
allGlobally: AUTHENTICATION.KIBANA_RBAC_USER,
readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER,
dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER,
dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER,
allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER,
readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER,
allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER,
},
},
].forEach(scenario => {
exportTest(`user with no access within the ${scenario.spaceId} space`, {
user: scenario.users.noAccess,
spaceId: scenario.spaceId,
tests: {
spaceAwareType: {
description: 'forbidden login and find visualization message',
statusCode: 403,
response: createExpectRbacForbidden('visualization'),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest(`superuser with the ${scenario.spaceId} space`, {
user: scenario.users.superuser,
spaceId: scenario.spaceId,
tests: {
spaceAwareType: {
description: 'only the visualization',
statusCode: 200,
response: createExpectVisualizationResults(scenario.spaceId),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest(`legacy user within the ${scenario.spaceId} space`, {
user: scenario.users.legacyAll,
spaceId: scenario.spaceId,
tests: {
spaceAwareType: {
description: 'forbidden login and find visualization message',
statusCode: 403,
response: createExpectRbacForbidden('visualization'),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest(`dual-privileges user within the ${scenario.spaceId} space`, {
user: scenario.users.dualAll,
spaceId: scenario.spaceId,
tests: {
spaceAwareType: {
description: 'only the visualization',
statusCode: 200,
response: createExpectVisualizationResults(scenario.spaceId),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, {
user: scenario.users.dualRead,
spaceId: scenario.spaceId,
tests: {
spaceAwareType: {
description: 'only the visualization',
statusCode: 200,
response: createExpectVisualizationResults(scenario.spaceId),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest(`rbac user with all globally within the ${scenario.spaceId} space`, {
user: scenario.users.allGlobally,
spaceId: scenario.spaceId,
tests: {
spaceAwareType: {
description: 'only the visualization',
statusCode: 200,
response: createExpectVisualizationResults(scenario.spaceId),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest(`rbac user with read globally within the ${scenario.spaceId} space`, {
user: scenario.users.readGlobally,
spaceId: scenario.spaceId,
tests: {
spaceAwareType: {
description: 'only the visualization',
statusCode: 200,
response: createExpectVisualizationResults(scenario.spaceId),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest(`rbac user with all at the space within the ${scenario.spaceId} space`, {
user: scenario.users.allAtSpace,
spaceId: scenario.spaceId,
tests: {
spaceAwareType: {
description: 'only the visualization',
statusCode: 200,
response: createExpectVisualizationResults(scenario.spaceId),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest(`rbac user with read at the space within the ${scenario.spaceId} space`, {
user: scenario.users.readAtSpace,
spaceId: scenario.spaceId,
tests: {
spaceAwareType: {
description: 'only the visualization',
statusCode: 200,
response: createExpectVisualizationResults(scenario.spaceId),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest(`rbac user with all at the other space within ${scenario.spaceId} space`, {
user: scenario.users.allAtOtherSpace,
spaceId: scenario.spaceId,
tests: {
spaceAwareType: {
description: 'forbidden login and find visualization message',
statusCode: 403,
response: createExpectRbacForbidden('visualization'),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
});
});
}

View file

@ -23,6 +23,7 @@ export default function({ getService, loadTestFile }: TestInvoker) {
loadTestFile(require.resolve('./bulk_get'));
loadTestFile(require.resolve('./create'));
loadTestFile(require.resolve('./delete'));
loadTestFile(require.resolve('./export'));
loadTestFile(require.resolve('./find'));
loadTestFile(require.resolve('./get'));
loadTestFile(require.resolve('./update'));

View file

@ -0,0 +1,200 @@
/*
* 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 { AUTHENTICATION } from '../../common/lib/authentication';
import { TestInvoker } from '../../common/lib/types';
import { exportTestSuiteFactory } from '../../common/suites/export';
// tslint:disable:no-default-export
export default function({ getService }: TestInvoker) {
const supertest = getService('supertestWithoutAuth');
const esArchiver = getService('esArchiver');
describe('export', () => {
const {
createExpectRbacForbidden,
expectTypeOrObjectsRequired,
createExpectVisualizationResults,
exportTest,
} = exportTestSuiteFactory(esArchiver, supertest);
exportTest('user with no access', {
user: AUTHENTICATION.NOT_A_KIBANA_USER,
tests: {
spaceAwareType: {
description: 'forbidden login and find visualization message',
statusCode: 403,
response: createExpectRbacForbidden('visualization'),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest('superuser', {
user: AUTHENTICATION.SUPERUSER,
tests: {
spaceAwareType: {
description: 'only the visualization',
statusCode: 200,
response: createExpectVisualizationResults(),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest('legacy user', {
user: AUTHENTICATION.KIBANA_LEGACY_USER,
tests: {
spaceAwareType: {
description: 'forbidden login and find visualization message',
statusCode: 403,
response: createExpectRbacForbidden('visualization'),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest('dual-privileges user', {
user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER,
tests: {
spaceAwareType: {
description: 'only the visualization',
statusCode: 200,
response: createExpectVisualizationResults(),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest('dual-privileges readonly user', {
user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER,
tests: {
spaceAwareType: {
description: 'only the visualization',
statusCode: 200,
response: createExpectVisualizationResults(),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest('rbac user with all globally', {
user: AUTHENTICATION.KIBANA_RBAC_USER,
tests: {
spaceAwareType: {
description: 'only the visualization',
statusCode: 200,
response: createExpectVisualizationResults(),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest('rbac user with read globally', {
user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER,
tests: {
spaceAwareType: {
description: 'only the visualization',
statusCode: 200,
response: createExpectVisualizationResults(),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest('rbac user with all at default space', {
user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER,
tests: {
spaceAwareType: {
description: 'only the visualization',
statusCode: 403,
response: createExpectRbacForbidden('visualization'),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest('rbac user with read at default space', {
user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER,
tests: {
spaceAwareType: {
description: 'only the visualization',
statusCode: 403,
response: createExpectRbacForbidden('visualization'),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest('rbac user with all at space_1', {
user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER,
tests: {
spaceAwareType: {
description: 'only the visualization',
statusCode: 403,
response: createExpectRbacForbidden('visualization'),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest('rbac user with read at space_1', {
user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER,
tests: {
spaceAwareType: {
description: 'only the visualization',
statusCode: 403,
response: createExpectRbacForbidden('visualization'),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
});
}

View file

@ -23,6 +23,7 @@ export default function({ getService, loadTestFile }: TestInvoker) {
loadTestFile(require.resolve('./bulk_get'));
loadTestFile(require.resolve('./create'));
loadTestFile(require.resolve('./delete'));
loadTestFile(require.resolve('./export'));
loadTestFile(require.resolve('./find'));
loadTestFile(require.resolve('./get'));
loadTestFile(require.resolve('./update'));

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { SPACES } from '../../common/lib/spaces';
import { TestInvoker } from '../../common/lib/types';
import { exportTestSuiteFactory } from '../../common/suites/export';
// tslint:disable:no-default-export
export default function({ getService }: TestInvoker) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const {
expectTypeOrObjectsRequired,
createExpectVisualizationResults,
exportTest,
} = exportTestSuiteFactory(esArchiver, supertest);
describe('export', () => {
exportTest('objects only within the current space (space_1)', {
...SPACES.SPACE_1,
tests: {
spaceAwareType: {
description: 'only the visualization',
statusCode: 200,
response: createExpectVisualizationResults(SPACES.SPACE_1.spaceId),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
exportTest('objects only within the current space (default)', {
...SPACES.DEFAULT,
tests: {
spaceAwareType: {
description: 'only the visualization',
statusCode: 200,
response: createExpectVisualizationResults(SPACES.DEFAULT.spaceId),
},
noTypeOrObjects: {
description: 'bad request, type or object is required',
statusCode: 400,
response: expectTypeOrObjectsRequired,
},
},
});
});
}

View file

@ -15,6 +15,7 @@ export default function({ loadTestFile }: TestInvoker) {
loadTestFile(require.resolve('./bulk_get'));
loadTestFile(require.resolve('./create'));
loadTestFile(require.resolve('./delete'));
loadTestFile(require.resolve('./export'));
loadTestFile(require.resolve('./find'));
loadTestFile(require.resolve('./get'));
loadTestFile(require.resolve('./update'));