[Saved Objects] Add support for bulkUpdate to SavedObjectsClient (#47540)

This PR adds support for `bulkUpdate` to the Saved Objects API and exposes it on all Saved Objects clients (base client, encrypted, spaces etc.).
This commit is contained in:
Gidi Meir Morris 2019-10-17 11:12:27 +01:00 committed by GitHub
parent 4956a762cd
commit 3f4024c398
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
76 changed files with 3245 additions and 300 deletions

View file

@ -79,6 +79,8 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [SavedObjectsBatchResponse](./kibana-plugin-public.savedobjectsbatchresponse.md) | |
| [SavedObjectsBulkCreateObject](./kibana-plugin-public.savedobjectsbulkcreateobject.md) | |
| [SavedObjectsBulkCreateOptions](./kibana-plugin-public.savedobjectsbulkcreateoptions.md) | |
| [SavedObjectsBulkUpdateObject](./kibana-plugin-public.savedobjectsbulkupdateobject.md) | |
| [SavedObjectsBulkUpdateOptions](./kibana-plugin-public.savedobjectsbulkupdateoptions.md) | |
| [SavedObjectsCreateOptions](./kibana-plugin-public.savedobjectscreateoptions.md) | |
| [SavedObjectsFindOptions](./kibana-plugin-public.savedobjectsfindoptions.md) | |
| [SavedObjectsFindResponsePublic](./kibana-plugin-public.savedobjectsfindresponsepublic.md) | Return type of the Saved Objects <code>find()</code> method.<!-- -->\*Note\*: this type is different between the Public and Server Saved Objects clients. |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [SavedObjectsBulkUpdateObject](./kibana-plugin-public.savedobjectsbulkupdateobject.md) &gt; [attributes](./kibana-plugin-public.savedobjectsbulkupdateobject.attributes.md)
## SavedObjectsBulkUpdateObject.attributes property
<b>Signature:</b>
```typescript
attributes: T;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [SavedObjectsBulkUpdateObject](./kibana-plugin-public.savedobjectsbulkupdateobject.md) &gt; [id](./kibana-plugin-public.savedobjectsbulkupdateobject.id.md)
## SavedObjectsBulkUpdateObject.id property
<b>Signature:</b>
```typescript
id: string;
```

View file

@ -0,0 +1,23 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [SavedObjectsBulkUpdateObject](./kibana-plugin-public.savedobjectsbulkupdateobject.md)
## SavedObjectsBulkUpdateObject interface
<b>Signature:</b>
```typescript
export interface SavedObjectsBulkUpdateObject<T extends SavedObjectAttributes = SavedObjectAttributes>
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [attributes](./kibana-plugin-public.savedobjectsbulkupdateobject.attributes.md) | <code>T</code> | |
| [id](./kibana-plugin-public.savedobjectsbulkupdateobject.id.md) | <code>string</code> | |
| [references](./kibana-plugin-public.savedobjectsbulkupdateobject.references.md) | <code>SavedObjectReference[]</code> | |
| [type](./kibana-plugin-public.savedobjectsbulkupdateobject.type.md) | <code>string</code> | |
| [version](./kibana-plugin-public.savedobjectsbulkupdateobject.version.md) | <code>string</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [SavedObjectsBulkUpdateObject](./kibana-plugin-public.savedobjectsbulkupdateobject.md) &gt; [references](./kibana-plugin-public.savedobjectsbulkupdateobject.references.md)
## SavedObjectsBulkUpdateObject.references property
<b>Signature:</b>
```typescript
references?: SavedObjectReference[];
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [SavedObjectsBulkUpdateObject](./kibana-plugin-public.savedobjectsbulkupdateobject.md) &gt; [type](./kibana-plugin-public.savedobjectsbulkupdateobject.type.md)
## SavedObjectsBulkUpdateObject.type property
<b>Signature:</b>
```typescript
type: string;
```

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [SavedObjectsBulkUpdateObject](./kibana-plugin-public.savedobjectsbulkupdateobject.md) &gt; [version](./kibana-plugin-public.savedobjectsbulkupdateobject.version.md)
## SavedObjectsBulkUpdateObject.version property
<b>Signature:</b>
```typescript
version?: string;
```

View file

@ -0,0 +1,19 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [SavedObjectsBulkUpdateOptions](./kibana-plugin-public.savedobjectsbulkupdateoptions.md)
## SavedObjectsBulkUpdateOptions interface
<b>Signature:</b>
```typescript
export interface SavedObjectsBulkUpdateOptions
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [namespace](./kibana-plugin-public.savedobjectsbulkupdateoptions.namespace.md) | <code>string</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [SavedObjectsBulkUpdateOptions](./kibana-plugin-public.savedobjectsbulkupdateoptions.md) &gt; [namespace](./kibana-plugin-public.savedobjectsbulkupdateoptions.namespace.md)
## SavedObjectsBulkUpdateOptions.namespace property
<b>Signature:</b>
```typescript
namespace?: string;
```

View file

@ -0,0 +1,26 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-public](./kibana-plugin-public.md) &gt; [SavedObjectsClient](./kibana-plugin-public.savedobjectsclient.md) &gt; [bulkUpdate](./kibana-plugin-public.savedobjectsclient.bulkupdate.md)
## SavedObjectsClient.bulkUpdate() method
Update multiple documents at once
<b>Signature:</b>
```typescript
bulkUpdate<T extends SavedObjectAttributes>(objects?: SavedObjectsBulkUpdateObject[]): Promise<SavedObjectsBatchResponse<SavedObjectAttributes>>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| objects | <code>SavedObjectsBulkUpdateObject[]</code> | |
<b>Returns:</b>
`Promise<SavedObjectsBatchResponse<SavedObjectAttributes>>`
The result of the update operation containing both failed and updated saved objects.

View file

@ -27,6 +27,7 @@ export declare class SavedObjectsClient
| Method | Modifiers | Description |
| --- | --- | --- |
| [bulkUpdate(objects)](./kibana-plugin-public.savedobjectsclient.bulkupdate.md) | | Update multiple documents at once |
| [update(type, id, attributes, { version, migrationVersion, references })](./kibana-plugin-public.savedobjectsclient.update.md) | | Updates an object |
## Remarks

View file

@ -89,6 +89,8 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [SavedObjectsBulkCreateObject](./kibana-plugin-server.savedobjectsbulkcreateobject.md) | |
| [SavedObjectsBulkGetObject](./kibana-plugin-server.savedobjectsbulkgetobject.md) | |
| [SavedObjectsBulkResponse](./kibana-plugin-server.savedobjectsbulkresponse.md) | |
| [SavedObjectsBulkUpdateObject](./kibana-plugin-server.savedobjectsbulkupdateobject.md) | |
| [SavedObjectsBulkUpdateResponse](./kibana-plugin-server.savedobjectsbulkupdateresponse.md) | |
| [SavedObjectsClientProviderOptions](./kibana-plugin-server.savedobjectsclientprovideroptions.md) | Options to control the creation of the Saved Objects Client. |
| [SavedObjectsClientWrapperOptions](./kibana-plugin-server.savedobjectsclientwrapperoptions.md) | Options passed to each SavedObjectsClientWrapperFactory to aid in creating the wrapper instance. |
| [SavedObjectsCreateOptions](./kibana-plugin-server.savedobjectscreateoptions.md) | |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SavedObjectsBulkUpdateObject](./kibana-plugin-server.savedobjectsbulkupdateobject.md) &gt; [attributes](./kibana-plugin-server.savedobjectsbulkupdateobject.attributes.md)
## SavedObjectsBulkUpdateObject.attributes property
The data for a Saved Object is stored as an object in the `attributes` property.
<b>Signature:</b>
```typescript
attributes: Partial<T>;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SavedObjectsBulkUpdateObject](./kibana-plugin-server.savedobjectsbulkupdateobject.md) &gt; [id](./kibana-plugin-server.savedobjectsbulkupdateobject.id.md)
## SavedObjectsBulkUpdateObject.id property
The ID of this Saved Object, guaranteed to be unique for all objects of the same `type`
<b>Signature:</b>
```typescript
id: string;
```

View file

@ -0,0 +1,21 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SavedObjectsBulkUpdateObject](./kibana-plugin-server.savedobjectsbulkupdateobject.md)
## SavedObjectsBulkUpdateObject interface
<b>Signature:</b>
```typescript
export interface SavedObjectsBulkUpdateObject<T extends SavedObjectAttributes = any> extends Pick<SavedObjectsUpdateOptions, 'version' | 'references'>
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [attributes](./kibana-plugin-server.savedobjectsbulkupdateobject.attributes.md) | <code>Partial&lt;T&gt;</code> | The data for a Saved Object is stored as an object in the <code>attributes</code> property. |
| [id](./kibana-plugin-server.savedobjectsbulkupdateobject.id.md) | <code>string</code> | The ID of this Saved Object, guaranteed to be unique for all objects of the same <code>type</code> |
| [type](./kibana-plugin-server.savedobjectsbulkupdateobject.type.md) | <code>string</code> | The type of this Saved Object. Each plugin can define it's own custom Saved Object types. |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SavedObjectsBulkUpdateObject](./kibana-plugin-server.savedobjectsbulkupdateobject.md) &gt; [type](./kibana-plugin-server.savedobjectsbulkupdateobject.type.md)
## SavedObjectsBulkUpdateObject.type property
The type of this Saved Object. Each plugin can define it's own custom Saved Object types.
<b>Signature:</b>
```typescript
type: string;
```

View file

@ -0,0 +1,19 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SavedObjectsBulkUpdateResponse](./kibana-plugin-server.savedobjectsbulkupdateresponse.md)
## SavedObjectsBulkUpdateResponse interface
<b>Signature:</b>
```typescript
export interface SavedObjectsBulkUpdateResponse<T extends SavedObjectAttributes = any>
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [saved\_objects](./kibana-plugin-server.savedobjectsbulkupdateresponse.saved_objects.md) | <code>Array&lt;SavedObjectsUpdateResponse&lt;T&gt;&gt;</code> | |

View file

@ -0,0 +1,11 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SavedObjectsBulkUpdateResponse](./kibana-plugin-server.savedobjectsbulkupdateresponse.md) &gt; [saved\_objects](./kibana-plugin-server.savedobjectsbulkupdateresponse.saved_objects.md)
## SavedObjectsBulkUpdateResponse.saved\_objects property
<b>Signature:</b>
```typescript
saved_objects: Array<SavedObjectsUpdateResponse<T>>;
```

View file

@ -0,0 +1,25 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [SavedObjectsClient](./kibana-plugin-server.savedobjectsclient.md) &gt; [bulkUpdate](./kibana-plugin-server.savedobjectsclient.bulkupdate.md)
## SavedObjectsClient.bulkUpdate() method
Bulk Updates multiple SavedObject at once
<b>Signature:</b>
```typescript
bulkUpdate<T extends SavedObjectAttributes = any>(objects: Array<SavedObjectsBulkUpdateObject<T>>, options?: SavedObjectsBaseOptions): Promise<SavedObjectsBulkUpdateResponse<T>>;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| objects | <code>Array&lt;SavedObjectsBulkUpdateObject&lt;T&gt;&gt;</code> | |
| options | <code>SavedObjectsBaseOptions</code> | |
<b>Returns:</b>
`Promise<SavedObjectsBulkUpdateResponse<T>>`

View file

@ -30,6 +30,7 @@ export declare class SavedObjectsClient
| --- | --- | --- |
| [bulkCreate(objects, options)](./kibana-plugin-server.savedobjectsclient.bulkcreate.md) | | Persists multiple documents batched together as a single request |
| [bulkGet(objects, options)](./kibana-plugin-server.savedobjectsclient.bulkget.md) | | Returns an array of objects by id |
| [bulkUpdate(objects, options)](./kibana-plugin-server.savedobjectsclient.bulkupdate.md) | | Bulk Updates multiple SavedObject at once |
| [create(type, attributes, options)](./kibana-plugin-server.savedobjectsclient.create.md) | | Persists a SavedObject |
| [delete(type, id, options)](./kibana-plugin-server.savedobjectsclient.delete.md) | | Deletes a SavedObject |
| [find(options)](./kibana-plugin-server.savedobjectsclient.find.md) | | Find all SavedObjects matching the search query |

View file

@ -15,6 +15,6 @@ export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions
| Property | Type | Description |
| --- | --- | --- |
| [references](./kibana-plugin-server.savedobjectsupdateoptions.references.md) | <code>SavedObjectReference[]</code> | |
| [version](./kibana-plugin-server.savedobjectsupdateoptions.version.md) | <code>string</code> | Ensures version matches that of persisted object |
| [references](./kibana-plugin-server.savedobjectsupdateoptions.references.md) | <code>SavedObjectReference[]</code> | A reference to another saved object. |
| [version](./kibana-plugin-server.savedobjectsupdateoptions.version.md) | <code>string</code> | An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. |

View file

@ -4,6 +4,8 @@
## SavedObjectsUpdateOptions.references property
A reference to another saved object.
<b>Signature:</b>
```typescript

View file

@ -4,7 +4,7 @@
## SavedObjectsUpdateOptions.version property
Ensures version matches that of persisted object
An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control.
<b>Signature:</b>

View file

@ -79,6 +79,8 @@ export {
SavedObjectsBatchResponse,
SavedObjectsBulkCreateObject,
SavedObjectsBulkCreateOptions,
SavedObjectsBulkUpdateObject,
SavedObjectsBulkUpdateOptions,
SavedObjectsCreateOptions,
SavedObjectsFindResponsePublic,
SavedObjectsUpdateOptions,

View file

@ -757,6 +757,26 @@ export interface SavedObjectsBulkCreateOptions {
overwrite?: boolean;
}
// @public (undocumented)
export interface SavedObjectsBulkUpdateObject<T extends SavedObjectAttributes = SavedObjectAttributes> {
// (undocumented)
attributes: T;
// (undocumented)
id: string;
// (undocumented)
references?: SavedObjectReference[];
// (undocumented)
type: string;
// (undocumented)
version?: string;
}
// @public (undocumented)
export interface SavedObjectsBulkUpdateOptions {
// (undocumented)
namespace?: string;
}
// @public
export class SavedObjectsClient {
// @internal
@ -766,6 +786,7 @@ export class SavedObjectsClient {
id: string;
type: string;
}[]) => Promise<SavedObjectsBatchResponse<SavedObjectAttributes>>;
bulkUpdate<T extends SavedObjectAttributes>(objects?: SavedObjectsBulkUpdateObject[]): Promise<SavedObjectsBatchResponse<SavedObjectAttributes>>;
create: <T extends SavedObjectAttributes>(type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise<SimpleSavedObject<T>>;
delete: (type: string, id: string) => Promise<{}>;
find: <T extends SavedObjectAttributes>(options: Pick<SavedObjectsFindOptions, "search" | "filter" | "type" | "searchFields" | "defaultSearchOperator" | "hasReference" | "sortField" | "page" | "perPage" | "fields">) => Promise<SavedObjectsFindResponsePublic<T>>;

View file

@ -21,11 +21,13 @@ export {
SavedObjectsBatchResponse,
SavedObjectsBulkCreateObject,
SavedObjectsBulkCreateOptions,
SavedObjectsBulkUpdateObject,
SavedObjectsClient,
SavedObjectsClientContract,
SavedObjectsCreateOptions,
SavedObjectsFindResponsePublic,
SavedObjectsUpdateOptions,
SavedObjectsBulkUpdateOptions,
} from './saved_objects_client';
export { SimpleSavedObject } from './simple_saved_object';
export { SavedObjectsStart } from './saved_objects_service';

View file

@ -322,6 +322,43 @@ describe('SavedObjectsClient', () => {
});
});
describe('#bulk_update', () => {
const bulkUpdateDoc = {
id: 'AVwSwFxtcMV38qjDZoQg',
type: 'config',
attributes: { title: 'Example title' },
version: 'foo',
};
beforeEach(() => {
http.fetch.mockResolvedValue({ saved_objects: [bulkUpdateDoc] });
});
test('resolves with array of SimpleSavedObject instances', async () => {
const response = savedObjectsClient.bulkUpdate([bulkUpdateDoc]);
await expect(response).resolves.toHaveProperty('savedObjects');
const result = await response;
expect(result.savedObjects).toHaveLength(1);
expect(result.savedObjects[0]).toBeInstanceOf(SimpleSavedObject);
});
test('makes HTTP call', async () => {
await savedObjectsClient.bulkUpdate([bulkUpdateDoc]);
expect(http.fetch.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/api/saved_objects/_bulk_update",
Object {
"body": "[{\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\",\\"type\\":\\"config\\",\\"attributes\\":{\\"title\\":\\"Example title\\"},\\"version\\":\\"foo\\"}]",
"method": "PUT",
"query": undefined,
},
],
]
`);
});
});
describe('#find', () => {
const object = { id: 'logstash-*', type: 'index-pattern', title: 'Test' };
@ -419,15 +456,15 @@ describe('SavedObjectsClient', () => {
};
http.fetch.mockRejectedValue(err);
return expect(savedObjectsClient.get(doc.type, doc.id)).rejects.toMatchInlineSnapshot(`
Object {
"body": "response body",
"res": Object {
"ok": false,
"redirected": false,
"status": 409,
"statusText": "Conflict",
},
}
`);
Object {
"body": "response body",
"res": Object {
"ok": false,
"redirected": false,
"status": 409,
"statusText": "Conflict",
},
}
`);
});
});

View file

@ -73,6 +73,22 @@ export interface SavedObjectsBulkCreateOptions {
overwrite?: boolean;
}
/** @public */
export interface SavedObjectsBulkUpdateObject<
T extends SavedObjectAttributes = SavedObjectAttributes
> {
type: string;
id: string;
attributes: T;
version?: string;
references?: SavedObjectReference[];
}
/** @public */
export interface SavedObjectsBulkUpdateOptions {
namespace?: string;
}
/** @public */
export interface SavedObjectsUpdateOptions {
version?: string;
@ -411,6 +427,27 @@ export class SavedObjectsClient {
});
}
/**
* Update multiple documents at once
*
* @param {array} objects - [{ type, id, attributes, options: { version, references } }]
* @returns The result of the update operation containing both failed and updated saved objects.
*/
public bulkUpdate<T extends SavedObjectAttributes>(objects: SavedObjectsBulkUpdateObject[] = []) {
const path = this.getPath(['_bulk_update']);
return this.savedObjectsFetch(path, {
method: 'PUT',
body: JSON.stringify(objects),
}).then(resp => {
resp.saved_objects = resp.saved_objects.map((d: SavedObject<T>) => this.createSavedObject(d));
return renameKeys<
PromiseType<ReturnType<SavedObjectsApi['bulkUpdate']>>,
SavedObjectsBatchResponse
>({ saved_objects: 'savedObjects' }, resp) as SavedObjectsBatchResponse;
});
}
private createSavedObject<T extends SavedObjectAttributes>(
options: SavedObject<T>
): SimpleSavedObject<T> {

View file

@ -24,6 +24,7 @@ const createStartContractMock = () => {
client: {
create: jest.fn(),
bulkCreate: jest.fn(),
bulkUpdate: jest.fn(),
delete: jest.fn(),
bulkGet: jest.fn(),
find: jest.fn(),

View file

@ -138,7 +138,9 @@ export {
export {
SavedObjectsBulkCreateObject,
SavedObjectsBulkGetObject,
SavedObjectsBulkUpdateObject,
SavedObjectsBulkResponse,
SavedObjectsBulkUpdateResponse,
SavedObjectsClient,
SavedObjectsClientProviderOptions,
SavedObjectsClientWrapperFactory,

View file

@ -18,6 +18,7 @@
*/
import { SavedObject } from '../types';
import { SavedObjectsClientMock } from '../../mocks';
import { getObjectReferencesToFetch, fetchNestedDependencies } from './inject_nested_depdendencies';
describe('getObjectReferencesToFetch()', () => {
@ -107,17 +108,8 @@ describe('getObjectReferencesToFetch()', () => {
});
});
describe('fetchNestedDependencies', () => {
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(),
};
describe('injectNestedDependencies', () => {
const savedObjectsClient = SavedObjectsClientMock.create();
afterEach(() => {
jest.resetAllMocks();
@ -487,6 +479,8 @@ describe('fetchNestedDependencies', () => {
statusCode: 404,
message: 'Not found',
},
attributes: {},
references: [],
},
{
id: '2',

View file

@ -20,7 +20,14 @@
import { Readable } from 'stream';
import { SavedObject } from '../types';
import { importSavedObjects } from './import_saved_objects';
import { SavedObjectsClientMock } from '../../mocks';
const emptyResponse = {
saved_objects: [],
total: 0,
per_page: 0,
page: 0,
};
describe('importSavedObjects()', () => {
const savedObjects: SavedObject[] = [
{
@ -56,16 +63,7 @@ describe('importSavedObjects()', () => {
references: [],
},
];
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(),
};
const savedObjectsClient = SavedObjectsClientMock.create();
beforeEach(() => {
jest.resetAllMocks();
@ -101,7 +99,7 @@ describe('importSavedObjects()', () => {
this.push(null);
},
});
savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] });
savedObjectsClient.find.mockResolvedValueOnce(emptyResponse);
savedObjectsClient.bulkCreate.mockResolvedValue({
saved_objects: savedObjects,
});
@ -184,7 +182,7 @@ describe('importSavedObjects()', () => {
this.push(null);
},
});
savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] });
savedObjectsClient.find.mockResolvedValueOnce(emptyResponse);
savedObjectsClient.bulkCreate.mockResolvedValue({
saved_objects: savedObjects,
});
@ -268,7 +266,7 @@ describe('importSavedObjects()', () => {
this.push(null);
},
});
savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] });
savedObjectsClient.find.mockResolvedValueOnce(emptyResponse);
savedObjectsClient.bulkCreate.mockResolvedValue({
saved_objects: savedObjects,
});
@ -351,7 +349,7 @@ describe('importSavedObjects()', () => {
this.push(null);
},
});
savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] });
savedObjectsClient.find.mockResolvedValueOnce(emptyResponse);
savedObjectsClient.bulkCreate.mockResolvedValue({
saved_objects: savedObjects.map(savedObject => ({
type: savedObject.type,
@ -360,6 +358,8 @@ describe('importSavedObjects()', () => {
statusCode: 409,
message: 'conflict',
},
attributes: {},
references: [],
})),
});
const result = await importSavedObjects({
@ -455,6 +455,8 @@ describe('importSavedObjects()', () => {
statusCode: 404,
message: 'Not found',
},
attributes: {},
references: [],
},
],
});
@ -530,7 +532,7 @@ describe('importSavedObjects()', () => {
this.push(null);
},
});
savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] });
savedObjectsClient.find.mockResolvedValueOnce(emptyResponse);
savedObjectsClient.bulkCreate.mockResolvedValue({
saved_objects: savedObjects,
});

View file

@ -20,6 +20,7 @@
import { Readable } from 'stream';
import { SavedObject } from '../types';
import { resolveImportErrors } from './resolve_import_errors';
import { SavedObjectsClientMock } from '../../mocks';
describe('resolveImportErrors()', () => {
const savedObjects: SavedObject[] = [
@ -62,16 +63,7 @@ describe('resolveImportErrors()', () => {
],
},
];
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(),
};
const savedObjectsClient = SavedObjectsClientMock.create();
beforeEach(() => {
jest.resetAllMocks();
@ -316,6 +308,8 @@ describe('resolveImportErrors()', () => {
statusCode: 409,
message: 'conflict',
},
attributes: {},
references: [],
})),
});
const result = await resolveImportErrors({
@ -416,6 +410,8 @@ describe('resolveImportErrors()', () => {
statusCode: 404,
message: 'Not found',
},
attributes: {},
references: [],
},
],
});

View file

@ -18,18 +18,10 @@
*/
import { getNonExistingReferenceAsKeys, validateReferences } from './validate_references';
import { SavedObjectsClientMock } from '../../mocks';
describe('getNonExistingReferenceAsKeys()', () => {
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(),
};
const savedObjectsClient = SavedObjectsClientMock.create();
beforeEach(() => {
jest.resetAllMocks();
@ -176,6 +168,8 @@ describe('getNonExistingReferenceAsKeys()', () => {
statusCode: 404,
message: 'Not found',
},
attributes: {},
references: [],
},
{
id: '3',
@ -184,6 +178,8 @@ describe('getNonExistingReferenceAsKeys()', () => {
statusCode: 404,
message: 'Not found',
},
attributes: {},
references: [],
},
],
});
@ -226,16 +222,7 @@ describe('getNonExistingReferenceAsKeys()', () => {
});
describe('validateReferences()', () => {
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(),
};
const savedObjectsClient = SavedObjectsClientMock.create();
beforeEach(() => {
jest.resetAllMocks();
@ -262,6 +249,8 @@ Object {
statusCode: 404,
message: 'Not found',
},
attributes: {},
references: [],
},
{
type: 'index-pattern',
@ -270,6 +259,8 @@ Object {
statusCode: 404,
message: 'Not found',
},
attributes: {},
references: [],
},
{
type: 'index-pattern',
@ -278,6 +269,8 @@ Object {
statusCode: 404,
message: 'Not found',
},
attributes: {},
references: [],
},
{
type: 'search',
@ -286,6 +279,8 @@ Object {
statusCode: 404,
message: 'Not found',
},
attributes: {},
references: [],
},
{
id: '8',
@ -611,6 +606,8 @@ Object {
statusCode: 400,
message: 'Error',
},
attributes: {},
references: [],
},
],
});

View file

@ -18,6 +18,7 @@
*/
import { delay } from 'bluebird';
import _ from 'lodash';
import { SavedObjectsRepository } from './repository';
import * as getSearchDslNS from './search_dsl/search_dsl';
@ -1963,6 +1964,459 @@ describe('SavedObjectsRepository', () => {
});
});
describe('#bulkUpdate', () => {
const { generateSavedObject, reset } = (() => {
let count = 0;
return {
generateSavedObject(overrides) {
count++;
return _.merge({
type: 'index-pattern',
id: `logstash-${count}`,
attributes: { title: `Testing ${count}` },
references: [
{
name: 'ref_0',
type: 'test',
id: '1',
},
],
}, overrides);
},
reset() {
count = 0;
}
};
})();
beforeEach(() => {
reset();
});
const mockValidResponse = objects =>
callAdminCluster.mockReturnValue({
items: objects.map(items => ({
update: {
_id: `${items.type}:${items.id}`,
_type: '_doc',
...mockVersionProps,
result: 'updated',
}
})),
});
it('waits until migrations are complete before proceeding', async () => {
const objects = [
generateSavedObject(),
generateSavedObject()
];
migrator.runMigrations = jest.fn(async () =>
expect(callAdminCluster).not.toHaveBeenCalled()
);
mockValidResponse(objects);
await expect(
savedObjectsRepository.bulkUpdate([
generateSavedObject(),
])
).resolves.toBeDefined();
expect(migrator.runMigrations).toHaveReturnedTimes(1);
});
it('returns current ES document, _seq_no and _primary_term encoded as version', async () => {
const objects = [
generateSavedObject(),
generateSavedObject()
];
mockValidResponse(objects);
const response = await savedObjectsRepository.bulkUpdate(objects);
expect(response.saved_objects[0]).toMatchObject({
..._.pick(objects[0], 'id', 'type', 'attributes'),
version: mockVersion,
references: objects[0].references
});
expect(response.saved_objects[1]).toMatchObject({
..._.pick(objects[1], 'id', 'type', 'attributes'),
version: mockVersion,
references: objects[1].references
});
});
it('handles a mix of succesfull updates and errors', async () => {
const objects = [
generateSavedObject(),
{
type: 'invalid-type',
id: 'invalid',
attributes: { title: 'invalid' }
},
generateSavedObject(),
generateSavedObject({
id: 'version_clash'
}),
];
callAdminCluster.mockReturnValue({
items: objects
// remove invalid from mocks
.filter(item => item.id !== 'invalid')
.map(items => {
switch(items.id) {
case 'version_clash':
return ({
update: {
_id: `${items.type}:${items.id}`,
_type: '_doc',
error: {
type: 'version_conflict_engine_exception'
}
}
});
default:
return ({
update: {
_id: `${items.type}:${items.id}`,
_type: '_doc',
...mockVersionProps,
result: 'updated',
}
});
}
}),
});
const { saved_objects: [
firstUpdatedObject,
invalidType,
secondUpdatedObject,
versionClashObject
] } = await savedObjectsRepository.bulkUpdate(objects);
expect(firstUpdatedObject).toMatchObject({
..._.pick(objects[0], 'id', 'type', 'attributes', 'references'),
version: mockVersion
});
expect(invalidType).toMatchObject({
..._.pick(objects[1], 'id', 'type'),
error: SavedObjectsErrorHelpers.createGenericNotFoundError('invalid-type', 'invalid').output.payload,
});
expect(secondUpdatedObject).toMatchObject({
..._.pick(objects[2], 'id', 'type', 'attributes', 'references'),
version: mockVersion
});
expect(versionClashObject).toMatchObject({
..._.pick(objects[3], 'id', 'type'),
error: { statusCode: 409, message: 'version conflict, document already exists' },
});
});
it('doesnt call Elasticsearch if there are no valid objects to update', async () => {
const objects = [
{
type: 'invalid-type',
id: 'invalid',
attributes: { title: 'invalid' }
},
{
type: 'invalid-type',
id: 'invalid 2',
attributes: { title: 'invalid' }
},
];
const { saved_objects: [
invalidType,
invalidType2
] } = await savedObjectsRepository.bulkUpdate(objects);
expect(callAdminCluster).not.toHaveBeenCalled();
expect(invalidType).toMatchObject({
..._.pick(objects[0], 'id', 'type'),
error: SavedObjectsErrorHelpers.createGenericNotFoundError('invalid-type', 'invalid').output.payload,
});
expect(invalidType2).toMatchObject({
..._.pick(objects[1], 'id', 'type'),
error: SavedObjectsErrorHelpers.createGenericNotFoundError('invalid-type', 'invalid 2').output.payload,
});
});
it('accepts version', async () => {
const objects = [
generateSavedObject({
version: encodeHitVersion({
_seq_no: 100,
_primary_term: 200,
}),
}),
generateSavedObject({
version: encodeHitVersion({
_seq_no: 300,
_primary_term: 400,
}),
})
];
mockValidResponse(objects);
await savedObjectsRepository.bulkUpdate(objects);
expect(callAdminCluster).toHaveBeenCalledTimes(1);
const [, { body: [{ update: firstUpdate },, { update: secondUpdate }] }] = callAdminCluster.mock.calls[0];
expect(firstUpdate).toMatchObject({
if_seq_no: 100,
if_primary_term: 200,
});
expect(secondUpdate).toMatchObject({
if_seq_no: 300,
if_primary_term: 400,
});
});
it('does not pass references if omitted', async () => {
const objects = [
{
type: 'index-pattern',
id: `logstash-no-ref`,
attributes: { title: `Testing no-ref` }
}
];
mockValidResponse(objects);
await savedObjectsRepository.bulkUpdate(objects);
expect(callAdminCluster).toHaveBeenCalledTimes(1);
const [, { body: [, { doc: firstDoc }] }] = callAdminCluster.mock.calls[0];
expect(firstDoc).not.toMatchObject({
references: [],
});
});
it('passes references if they are provided', async () => {
const objects = [
generateSavedObject({
references: [
{
name: 'ref_0',
type: 'test',
id: '1',
},
],
})
];
mockValidResponse(objects);
await savedObjectsRepository.bulkUpdate(objects);
expect(callAdminCluster).toHaveBeenCalledTimes(1);
const [, { body: [, { doc }] } ] = callAdminCluster.mock.calls[0];
expect(doc).toMatchObject({
references: [{
name: 'ref_0',
type: 'test',
id: '1',
}],
});
});
it('passes empty references array if empty references array is provided', async () => {
const objects = [
{
type: 'index-pattern',
id: `logstash-no-ref`,
attributes: { title: `Testing no-ref` },
references: []
}
];
mockValidResponse(objects);
await savedObjectsRepository.bulkUpdate(objects);
expect(callAdminCluster).toHaveBeenCalledTimes(1);
const [, { body: [, { doc }] } ] = callAdminCluster.mock.calls[0];
expect(doc).toMatchObject({
references: [],
});
});
it(`prepends namespace to the id but doesn't add namespace to body when providing namespace for namespaced type`, async () => {
const objects = [
generateSavedObject(),
generateSavedObject()
];
mockValidResponse(objects);
await savedObjectsRepository.bulkUpdate(objects, {
namespace: 'foo-namespace'
});
const [,
{ body: [
{ update: firstUpdate },
{ doc: firstUpdateDoc },
{ update: secondUpdate },
{ doc: secondUpdateDoc }
]
}
] = callAdminCluster.mock.calls[0];
expect(firstUpdate).toMatchObject({
_id: 'foo-namespace:index-pattern:logstash-1',
_index: '.kibana-test',
});
expect(firstUpdateDoc).toMatchObject({
updated_at: mockTimestamp,
'index-pattern': { title: 'Testing 1' },
references: [
{
name: 'ref_0',
type: 'test',
id: '1',
},
],
});
expect(secondUpdate).toMatchObject({
_id: 'foo-namespace:index-pattern:logstash-2',
_index: '.kibana-test',
});
expect(secondUpdateDoc).toMatchObject({
updated_at: mockTimestamp,
'index-pattern': { title: 'Testing 2' },
references: [
{
name: 'ref_0',
type: 'test',
id: '1',
},
],
});
expect(onBeforeWrite).toHaveBeenCalledTimes(1);
});
it(`doesn't prepend namespace to the id or add namespace property when providing no namespace for namespaced type`, async () => {
const objects = [
generateSavedObject(),
generateSavedObject()
];
mockValidResponse(objects);
await savedObjectsRepository.bulkUpdate(objects);
const [,
{ body: [
{ update: firstUpdate },
{ doc: firstUpdateDoc },
{ update: secondUpdate },
{ doc: secondUpdateDoc }
]
}
] = callAdminCluster.mock.calls[0];
expect(firstUpdate).toMatchObject({
_id: 'index-pattern:logstash-1',
_index: '.kibana-test',
});
expect(firstUpdateDoc).toMatchObject({
updated_at: mockTimestamp,
'index-pattern': { title: 'Testing 1' },
references: [
{
name: 'ref_0',
type: 'test',
id: '1',
},
],
});
expect(secondUpdate).toMatchObject({
_id: 'index-pattern:logstash-2',
_index: '.kibana-test',
});
expect(secondUpdateDoc).toMatchObject({
updated_at: mockTimestamp,
'index-pattern': { title: 'Testing 2' },
references: [
{
name: 'ref_0',
type: 'test',
id: '1',
},
],
});
expect(onBeforeWrite).toHaveBeenCalledTimes(1);
});
it(`doesn't prepend namespace to the id or add namespace property when providing namespace for namespace agnostic type`, async () => {
const objects = [
generateSavedObject({
type: 'globaltype',
id: 'foo',
namespace: 'foo-namespace'
})
];
mockValidResponse(objects);
await savedObjectsRepository.bulkUpdate(objects);
const [,
{ body: [{ update }, { doc }] }
] = callAdminCluster.mock.calls[0];
expect(update).toMatchObject({
_id: 'globaltype:foo',
_index: '.kibana-test',
});
expect(doc).toMatchObject({
updated_at: mockTimestamp,
globaltype: { title: 'Testing 1' },
references: [
{
name: 'ref_0',
type: 'test',
id: '1',
},
],
});
});
});
describe('#incrementCounter', () => {
beforeEach(() => {
callAdminCluster.mockImplementation((method, params) => ({

View file

@ -34,10 +34,12 @@ import {
SavedObjectsBulkCreateObject,
SavedObjectsBulkGetObject,
SavedObjectsBulkResponse,
SavedObjectsBulkUpdateResponse,
SavedObjectsCreateOptions,
SavedObjectsFindResponse,
SavedObjectsUpdateOptions,
SavedObjectsUpdateResponse,
SavedObjectsBulkUpdateObject,
} from '../saved_objects_client';
import {
SavedObject,
@ -279,22 +281,12 @@ export class SavedObjectsRepository {
const id = requestedId || responseId;
if (error) {
if (error.type === 'version_conflict_engine_exception') {
return {
id,
type,
error: { statusCode: 409, message: 'version conflict, document already exists' },
};
}
return {
id,
type,
error: {
message: error.reason || JSON.stringify(error),
},
error: getBulkOperationError(error, type, id),
};
}
return {
id,
type,
@ -673,6 +665,109 @@ export class SavedObjectsRepository {
};
}
/**
* Updates multiple objects in bulk
*
* @param {array} objects - [{ type, id, attributes, options: { version, namespace } references }]
* @property {string} options.version - ensures version matches that of persisted object
* @property {string} [options.namespace]
* @returns {promise} - {saved_objects: [[{ id, type, version, references, attributes, error: { message } }]}
*/
async bulkUpdate<T extends SavedObjectAttributes = any>(
objects: Array<SavedObjectsBulkUpdateObject<T>>,
options: SavedObjectsBaseOptions = {}
): Promise<SavedObjectsBulkUpdateResponse<T>> {
const time = this._getCurrentTime();
const bulkUpdateParams: object[] = [];
let requestIndexCounter = 0;
const expectedResults: Array<Either<any, any>> = objects.map(object => {
const { type, id } = object;
if (!this._allowedTypes.includes(type)) {
return {
tag: 'Left' as 'Left',
error: {
id,
type,
error: SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload,
},
};
}
const { attributes, references, version } = object;
const { namespace } = options;
const documentToSave = {
[type]: attributes,
updated_at: time,
references,
};
if (!Array.isArray(documentToSave.references)) {
delete documentToSave.references;
}
const expectedResult = {
type,
id,
esRequestIndex: requestIndexCounter++,
documentToSave,
};
bulkUpdateParams.push(
{
update: {
_id: this._serializer.generateRawId(namespace, type, id),
_index: this.getIndexForType(type),
...(version && decodeRequestVersion(version)),
},
},
{ doc: documentToSave }
);
return { tag: 'Right' as 'Right', value: expectedResult };
});
const esResponse = bulkUpdateParams.length
? await this._writeToCluster('bulk', {
refresh: 'wait_for',
body: bulkUpdateParams,
})
: {};
return {
saved_objects: expectedResults.map(expectedResult => {
if (isLeft(expectedResult)) {
return expectedResult.error;
}
const { type, id, documentToSave, esRequestIndex } = expectedResult.value;
const response = esResponse.items[esRequestIndex];
const { error, _seq_no: seqNo, _primary_term: primaryTerm } = Object.values(
response
)[0] as any;
const { [type]: attributes, references, updated_at } = documentToSave;
if (error) {
return {
id,
type,
error: getBulkOperationError(error, type, id),
};
}
return {
id,
type,
updated_at,
version: encodeVersion(seqNo, primaryTerm),
attributes,
references,
};
}),
};
}
/**
* Increases a counter field by one. Creates the document if one doesn't exist for the given id.
*
@ -802,3 +897,16 @@ export class SavedObjectsRepository {
return omit(savedObject, 'namespace');
}
}
function getBulkOperationError(error: { type: string; reason?: string }, type: string, id: string) {
switch (error.type) {
case 'version_conflict_engine_exception':
return { statusCode: 409, message: 'version conflict, document already exists' };
case 'document_missing_exception':
return SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload;
default:
return {
message: error.reason || JSON.stringify(error),
};
}
}

View file

@ -25,6 +25,7 @@ const create = () =>
errors: SavedObjectsErrorHelpers,
create: jest.fn(),
bulkCreate: jest.fn(),
bulkUpdate: jest.fn(),
delete: jest.fn(),
bulkGet: jest.fn(),
find: jest.fn(),

View file

@ -127,3 +127,21 @@ test(`#update`, async () => {
expect(mockRepository.update).toHaveBeenCalledWith(type, id, attributes, options);
expect(result).toBe(returnValue);
});
test(`#bulkUpdate`, async () => {
const returnValue = Symbol();
const mockRepository = {
bulkUpdate: jest.fn().mockResolvedValue(returnValue),
};
const client = new SavedObjectsClient(mockRepository);
const type = Symbol();
const id = Symbol();
const attributes = Symbol();
const version = Symbol();
const namespace = Symbol();
const result = await client.bulkUpdate([{ type, id, attributes, version }], { namespace });
expect(mockRepository.bulkUpdate).toHaveBeenCalledWith([{ type, id, attributes, version }], { namespace });
expect(result).toBe(returnValue);
});

View file

@ -55,6 +55,20 @@ export interface SavedObjectsBulkCreateObject<T extends SavedObjectAttributes =
migrationVersion?: SavedObjectsMigrationVersion;
}
/**
*
* @public
*/
export interface SavedObjectsBulkUpdateObject<T extends SavedObjectAttributes = any>
extends Pick<SavedObjectsUpdateOptions, 'version' | 'references'> {
/** The ID of this Saved Object, guaranteed to be unique for all objects of the same `type` */
id: string;
/** The type of this Saved Object. Each plugin can define it's own custom Saved Object types. */
type: string;
/** {@inheritdoc SavedObjectAttributes} */
attributes: Partial<T>;
}
/**
*
* @public
@ -83,8 +97,9 @@ export interface SavedObjectsFindResponse<T extends SavedObjectAttributes = any>
* @public
*/
export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions {
/** Ensures version matches that of persisted object */
/** An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. */
version?: string;
/** {@inheritdoc SavedObjectReference} */
references?: SavedObjectReference[];
}
@ -107,6 +122,14 @@ export interface SavedObjectsBulkResponse<T extends SavedObjectAttributes = any>
saved_objects: Array<SavedObject<T>>;
}
/**
*
* @public
*/
export interface SavedObjectsBulkUpdateResponse<T extends SavedObjectAttributes = any> {
saved_objects: Array<SavedObjectsUpdateResponse<T>>;
}
/**
*
* @public
@ -229,4 +252,16 @@ export class SavedObjectsClient {
): Promise<SavedObjectsUpdateResponse<T>> {
return await this._repository.update(type, id, attributes, options);
}
/**
* Bulk Updates multiple SavedObject at once
*
* @param objects
*/
async bulkUpdate<T extends SavedObjectAttributes = any>(
objects: Array<SavedObjectsBulkUpdateObject<T>>,
options?: SavedObjectsBaseOptions
): Promise<SavedObjectsBulkUpdateResponse<T>> {
return await this._repository.bulkUpdate(objects, options);
}
}

View file

@ -1189,12 +1189,26 @@ export interface SavedObjectsBulkResponse<T extends SavedObjectAttributes = any>
saved_objects: Array<SavedObject<T>>;
}
// @public (undocumented)
export interface SavedObjectsBulkUpdateObject<T extends SavedObjectAttributes = any> extends Pick<SavedObjectsUpdateOptions, 'version' | 'references'> {
attributes: Partial<T>;
id: string;
type: string;
}
// @public (undocumented)
export interface SavedObjectsBulkUpdateResponse<T extends SavedObjectAttributes = any> {
// (undocumented)
saved_objects: Array<SavedObjectsUpdateResponse<T>>;
}
// @public (undocumented)
export class SavedObjectsClient {
// Warning: (ae-forgotten-export) The symbol "SavedObjectsRepository" needs to be exported by the entry point index.d.ts
constructor(repository: SavedObjectsRepository);
bulkCreate<T extends SavedObjectAttributes = any>(objects: Array<SavedObjectsBulkCreateObject<T>>, options?: SavedObjectsCreateOptions): Promise<SavedObjectsBulkResponse<T>>;
bulkGet<T extends SavedObjectAttributes = any>(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise<SavedObjectsBulkResponse<T>>;
bulkUpdate<T extends SavedObjectAttributes = any>(objects: Array<SavedObjectsBulkUpdateObject<T>>, options?: SavedObjectsBaseOptions): Promise<SavedObjectsBulkUpdateResponse<T>>;
create<T extends SavedObjectAttributes = any>(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise<SavedObject<T>>;
delete(type: string, id: string, options?: SavedObjectsBaseOptions): Promise<{}>;
// (undocumented)
@ -1539,7 +1553,6 @@ export class SavedObjectsSerializer {
// @public (undocumented)
export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions {
// (undocumented)
references?: SavedObjectReference[];
version?: string;
}

View file

@ -28,6 +28,7 @@ export interface SavedObjectsClientStub {
create: sinon.SinonStub<any[], any>;
bulkCreate: sinon.SinonStub<any[], any>;
bulkGet: sinon.SinonStub<any[], any>;
bulkUpdate: sinon.SinonStub<any[], any>;
delete: sinon.SinonStub<any[], any>;
find: sinon.SinonStub<any[], any>;
errors: typeof savedObjectsClientErrors;
@ -41,6 +42,7 @@ export function createObjectsClientStub(esDocSource = {}): SavedObjectsClientStu
errors: savedObjectsClientErrors,
bulkCreate: sinon.stub(),
bulkGet: sinon.stub(),
bulkUpdate: sinon.stub(),
delete: sinon.stub(),
find: sinon.stub(),
};

View file

@ -0,0 +1,100 @@
/*
* 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 { mapValuesOfMap, groupIntoMap } from './map_utils';
describe('groupIntoMap', () => {
it('returns an empty map when there are no items to map', () => {
const groupBy = jest.fn();
expect(groupIntoMap([], groupBy)).toEqual(new Map());
expect(groupBy).not.toHaveBeenCalled();
});
it('calls groupBy for each item in the collection', () => {
const groupBy = jest.fn();
groupIntoMap([{ id: 1 }, { id: 2 }, { id: 3 }], groupBy);
expect(groupBy).toHaveBeenCalledTimes(3);
expect(groupBy).toHaveBeenCalledWith({ id: 1 });
expect(groupBy).toHaveBeenCalledWith({ id: 2 });
expect(groupBy).toHaveBeenCalledWith({ id: 3 });
});
it('returns each item in the key returned by groupBy', () => {
const groupBy = (item: { id: number }) => item.id;
expect(groupIntoMap([{ id: 1 }, { id: 2 }, { id: 3 }], groupBy)).toEqual(
new Map([[1, [{ id: 1 }]], [2, [{ id: 2 }]], [3, [{ id: 3 }]]])
);
});
it('groups items under the same key returned by groupBy', () => {
const groupBy = (item: { id: number }) => (item.id % 2 === 0 ? 'even' : 'odd');
const expectedResult = new Map();
expectedResult.set('even', [{ id: 2 }]);
expectedResult.set('odd', [{ id: 1 }, { id: 3 }]);
expect(groupIntoMap([{ id: 1 }, { id: 2 }, { id: 3 }], groupBy)).toEqual(expectedResult);
});
it('supports Symbols as keys', () => {
const even = Symbol('even');
const odd = Symbol('odd');
const groupBy = (item: { id: number }) => (item.id % 2 === 0 ? even : odd);
const expectedResult = new Map();
expectedResult.set(even, [{ id: 2 }]);
expectedResult.set(odd, [{ id: 1 }, { id: 3 }]);
expect(groupIntoMap([{ id: 1 }, { id: 2 }, { id: 3 }], groupBy)).toEqual(expectedResult);
});
});
describe('mapValuesOfMap', () => {
it('applys the mapper to each value in a map', () => {
const mapper = jest.fn();
const even = Symbol('even');
const odd = Symbol('odd');
const map = new Map();
map.set(even, 2);
map.set(odd, 1);
mapValuesOfMap(map, mapper);
expect(mapper).toHaveBeenCalledWith(1);
expect(mapper).toHaveBeenCalledWith(2);
});
it('returns a new map with each value mapped to the value returned by the mapper', () => {
const mapper = (i: number) => i * 3;
const even = Symbol('even');
const odd = Symbol('odd');
const map = new Map();
map.set(even, 2);
map.set(odd, 1);
expect(mapValuesOfMap(map, mapper)).toEqual(new Map([[even, 6], [odd, 3]]));
expect(map.get(odd)).toEqual(1);
expect(map.get(even)).toEqual(2);
});
});

View file

@ -0,0 +1,37 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export function mapValuesOfMap<T, G, H>(map: Map<T, G>, mapper: (item: G) => H): Map<T, H> {
const result = new Map();
for (const [key, value] of map.entries()) {
result.set(key, mapper(value));
}
return result;
}
export function groupIntoMap<T, G, H>(collection: T[], groupBy: (item: T) => G): Map<G, T[]> {
const map = new Map<G, T[]>();
collection.forEach(item => {
const key = groupBy(item);
const values = map.get(key) || [];
values.push(item);
map.set(key, values);
});
return map;
}

View file

@ -279,6 +279,7 @@ exports[`QueryBarInput Should disable autoFocus on EuiFieldText when disableAuto
"client": Object {
"bulkCreate": [MockFunction],
"bulkGet": [MockFunction],
"bulkUpdate": [MockFunction],
"create": [MockFunction],
"delete": [MockFunction],
"find": [MockFunction],
@ -824,6 +825,7 @@ exports[`QueryBarInput Should disable autoFocus on EuiFieldText when disableAuto
"client": Object {
"bulkCreate": [MockFunction],
"bulkGet": [MockFunction],
"bulkUpdate": [MockFunction],
"create": [MockFunction],
"delete": [MockFunction],
"find": [MockFunction],
@ -1357,6 +1359,7 @@ exports[`QueryBarInput Should pass the query language to the language switcher 1
"client": Object {
"bulkCreate": [MockFunction],
"bulkGet": [MockFunction],
"bulkUpdate": [MockFunction],
"create": [MockFunction],
"delete": [MockFunction],
"find": [MockFunction],
@ -1899,6 +1902,7 @@ exports[`QueryBarInput Should pass the query language to the language switcher 1
"client": Object {
"bulkCreate": [MockFunction],
"bulkGet": [MockFunction],
"bulkUpdate": [MockFunction],
"create": [MockFunction],
"delete": [MockFunction],
"find": [MockFunction],
@ -2432,6 +2436,7 @@ exports[`QueryBarInput Should render the given query 1`] = `
"client": Object {
"bulkCreate": [MockFunction],
"bulkGet": [MockFunction],
"bulkUpdate": [MockFunction],
"create": [MockFunction],
"delete": [MockFunction],
"find": [MockFunction],
@ -2974,6 +2979,7 @@ exports[`QueryBarInput Should render the given query 1`] = `
"client": Object {
"bulkCreate": [MockFunction],
"bulkGet": [MockFunction],
"bulkUpdate": [MockFunction],
"create": [MockFunction],
"delete": [MockFunction],
"find": [MockFunction],

View file

@ -17,10 +17,11 @@
* under the License.
*/
import { SavedObject, SavedObjectsClient } from 'src/core/server';
import { SavedObject, SavedObjectAttributes } from 'src/core/server';
import { collectReferencesDeep } from './collect_references_deep';
import { SavedObjectsClientMock } from '../../../../../../core/server/mocks';
const data = [
const data: Array<SavedObject<SavedObjectAttributes>> = [
{
id: '1',
type: 'dashboard',
@ -78,6 +79,7 @@ const data = [
attributes: {
title: 'pattern*',
},
references: [],
},
{
id: '5',
@ -100,97 +102,93 @@ const data = [
];
test('collects dashboard and all dependencies', async () => {
const savedObjectClient = ({
errors: {} as any,
create: jest.fn(),
bulkCreate: jest.fn(),
delete: jest.fn(),
find: jest.fn(),
get: jest.fn(),
update: jest.fn(),
bulkGet: jest.fn(getObjects => {
return {
saved_objects: getObjects.map((obj: SavedObject) =>
data.find(row => row.id === obj.id && row.type === obj.type)
),
};
}),
} as unknown) as SavedObjectsClient;
const savedObjectClient = SavedObjectsClientMock.create();
savedObjectClient.bulkGet.mockImplementation(objects => {
if (!objects) {
throw new Error('Invalid test data');
}
return Promise.resolve({
saved_objects: objects.map(
(obj: any) => data.find(row => row.id === obj.id && row.type === obj.type)!
),
});
});
const objects = await collectReferencesDeep(savedObjectClient, [{ type: 'dashboard', id: '1' }]);
expect(objects).toMatchInlineSnapshot(`
Array [
Object {
"attributes": Object {
"panelsJSON": "[{\\"panelRefName\\":\\"panel_0\\"},{\\"panelRefName\\":\\"panel_1\\"}]",
},
"id": "1",
"references": Array [
Array [
Object {
"attributes": Object {
"panelsJSON": "[{\\"panelRefName\\":\\"panel_0\\"},{\\"panelRefName\\":\\"panel_1\\"}]",
},
"id": "1",
"references": Array [
Object {
"id": "2",
"name": "panel_0",
"type": "visualization",
},
Object {
"id": "3",
"name": "panel_1",
"type": "visualization",
},
],
"type": "dashboard",
},
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.index\\"}",
},
},
"id": "2",
"name": "panel_0",
"references": Array [
Object {
"id": "4",
"name": "kibanaSavedObjectMeta.searchSourceJSON.index",
"type": "index-pattern",
},
],
"type": "visualization",
},
Object {
"attributes": Object {
"savedSearchRefName": "search_0",
},
"id": "3",
"name": "panel_1",
"references": Array [
Object {
"id": "5",
"name": "search_0",
"type": "search",
},
],
"type": "visualization",
},
],
"type": "dashboard",
},
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.index\\"}",
},
},
"id": "2",
"references": Array [
Object {
"attributes": Object {
"title": "pattern*",
},
"id": "4",
"name": "kibanaSavedObjectMeta.searchSourceJSON.index",
"references": Array [],
"type": "index-pattern",
},
],
"type": "visualization",
},
Object {
"attributes": Object {
"savedSearchRefName": "search_0",
},
"id": "3",
"references": Array [
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.index\\"}",
},
},
"id": "5",
"name": "search_0",
"references": Array [
Object {
"id": "4",
"name": "kibanaSavedObjectMeta.searchSourceJSON.index",
"type": "index-pattern",
},
],
"type": "search",
},
],
"type": "visualization",
},
Object {
"attributes": Object {
"title": "pattern*",
},
"id": "4",
"type": "index-pattern",
},
Object {
"attributes": Object {
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{\\"indexRefName\\":\\"kibanaSavedObjectMeta.searchSourceJSON.index\\"}",
},
},
"id": "5",
"references": Array [
Object {
"id": "4",
"name": "kibanaSavedObjectMeta.searchSourceJSON.index",
"type": "index-pattern",
},
],
"type": "search",
},
]
`);
]
`);
});

View file

@ -20,22 +20,18 @@
import Hapi from 'hapi';
import { createMockServer } from './_mock_server';
import { createBulkGetRoute } from './bulk_get';
import { SavedObjectsClientMock } from '../../../../core/server/mocks';
describe('POST /api/saved_objects/_bulk_get', () => {
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(),
};
const savedObjectsClient = SavedObjectsClientMock.create();
beforeEach(() => {
savedObjectsClient.bulkGet.mockImplementation(() => Promise.resolve(''));
savedObjectsClient.bulkGet.mockImplementation(() =>
Promise.resolve({
saved_objects: [],
})
);
server = createMockServer();
const prereqs = {
getSavedObjectsClient: {
@ -73,6 +69,7 @@ describe('POST /api/saved_objects/_bulk_get', () => {
title: 'logstash-*',
version: 'foo',
references: [],
attributes: {},
},
],
};

View file

@ -0,0 +1,148 @@
/*
* 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 { createBulkUpdateRoute } from './bulk_update';
import { SavedObjectsClientMock } from '../../../../core/server/mocks';
describe('PUT /api/saved_objects/_bulk_update', () => {
let server: Hapi.Server;
const savedObjectsClient = SavedObjectsClientMock.create();
beforeEach(() => {
server = createMockServer();
const prereqs = {
getSavedObjectsClient: {
assign: 'savedObjectsClient',
method() {
return savedObjectsClient;
},
},
};
server.route(createBulkUpdateRoute(prereqs));
});
afterEach(() => {
savedObjectsClient.bulkUpdate.mockReset();
});
it('formats successful response', async () => {
const request = {
method: 'PUT',
url: '/api/saved_objects/_bulk_update',
payload: [
{
type: 'visualization',
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
attributes: {
title: 'An existing visualization',
},
},
{
type: 'dashboard',
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
attributes: {
title: 'An existing dashboard',
},
},
],
};
const time = Date.now().toLocaleString();
const clientResponse = [
{
type: 'visualization',
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
updated_at: time,
version: 'version',
references: undefined,
attributes: {
title: 'An existing visualization',
},
},
{
type: 'dashboard',
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
updated_at: time,
version: 'version',
references: undefined,
attributes: {
title: 'An existing dashboard',
},
},
];
savedObjectsClient.bulkUpdate.mockImplementation(() =>
Promise.resolve({ saved_objects: clientResponse })
);
const { payload, statusCode } = await server.inject(request);
const response = JSON.parse(payload);
expect(statusCode).toBe(200);
expect(response).toEqual({ saved_objects: clientResponse });
});
it('calls upon savedObjectClient.bulkUpdate', async () => {
const request = {
method: 'PUT',
url: '/api/saved_objects/_bulk_update',
payload: [
{
type: 'visualization',
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
attributes: {
title: 'An existing visualization',
},
},
{
type: 'dashboard',
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
attributes: {
title: 'An existing dashboard',
},
},
],
};
savedObjectsClient.bulkUpdate.mockImplementation(() => Promise.resolve({ saved_objects: [] }));
await server.inject(request);
expect(savedObjectsClient.bulkUpdate).toHaveBeenCalledWith([
{
type: 'visualization',
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
attributes: {
title: 'An existing visualization',
},
},
{
type: 'dashboard',
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
attributes: {
title: 'An existing dashboard',
},
},
]);
});
});

View file

@ -0,0 +1,61 @@
/*
* 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 { SavedObjectsClient, SavedObjectsBulkUpdateObject } from 'src/core/server';
import { Prerequisites } from './types';
interface BulkUpdateRequest extends Hapi.Request {
pre: {
savedObjectsClient: SavedObjectsClient;
};
payload: SavedObjectsBulkUpdateObject[];
}
export const createBulkUpdateRoute = (prereqs: Prerequisites) => {
return {
path: '/api/saved_objects/_bulk_update',
method: 'PUT',
config: {
pre: [prereqs.getSavedObjectsClient],
validate: {
payload: Joi.array().items(
Joi.object({
type: Joi.string().required(),
id: Joi.string().required(),
attributes: Joi.object().required(),
version: Joi.string(),
references: Joi.array().items(
Joi.object().keys({
name: Joi.string().required(),
type: Joi.string().required(),
id: Joi.string().required(),
})
),
})
),
},
handler(request: BulkUpdateRequest) {
const { savedObjectsClient } = request.pre;
return savedObjectsClient.bulkUpdate(request.payload);
},
},
};
};

View file

@ -20,22 +20,22 @@
import Hapi from 'hapi';
import { createMockServer } from './_mock_server';
import { createCreateRoute } from './create';
import { SavedObjectsClientMock } from '../../../../core/server/mocks';
describe('POST /api/saved_objects/{type}', () => {
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(),
const clientResponse = {
id: 'logstash-*',
type: 'index-pattern',
title: 'logstash-*',
version: 'foo',
references: [],
attributes: {},
};
const savedObjectsClient = SavedObjectsClientMock.create();
beforeEach(() => {
savedObjectsClient.create.mockImplementation(() => Promise.resolve(''));
savedObjectsClient.create.mockImplementation(() => Promise.resolve(clientResponse));
server = createMockServer();
const prereqs = {
@ -65,15 +65,6 @@ describe('POST /api/saved_objects/{type}', () => {
},
};
const clientResponse = {
type: 'index-pattern',
id: 'logstash-*',
title: 'Testing',
references: [],
};
savedObjectsClient.create.mockImplementation(() => Promise.resolve(clientResponse));
const { payload, statusCode } = await server.inject(request);
const response = JSON.parse(payload);

View file

@ -20,19 +20,11 @@
import Hapi from 'hapi';
import { createMockServer } from './_mock_server';
import { createDeleteRoute } from './delete';
import { SavedObjectsClientMock } from '../../../../core/server/mocks';
describe('DELETE /api/saved_objects/{type}/{id}', () => {
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(),
};
const savedObjectsClient = SavedObjectsClientMock.create();
beforeEach(() => {
savedObjectsClient.delete.mockImplementation(() => Promise.resolve('{}'));

View file

@ -28,20 +28,15 @@ import * as exportMock from '../../../../core/server/saved_objects/export';
import { createMockServer } from './_mock_server';
import { createExportRoute } from './export';
import { createListStream } from '../../../utils/streams';
import { SavedObjectsClientMock } from '../../../../core/server/mocks';
const getSortedObjectsForExport = exportMock.getSortedObjectsForExport as jest.Mock;
describe('POST /api/saved_objects/_export', () => {
let server: Hapi.Server;
const savedObjectsClient = {
...SavedObjectsClientMock.create(),
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(() => {
@ -164,6 +159,7 @@ describe('POST /api/saved_objects/_export', () => {
"savedObjectsClient": Object {
"bulkCreate": [MockFunction],
"bulkGet": [MockFunction],
"bulkUpdate": [MockFunction],
"create": [MockFunction],
"delete": [MockFunction],
"errors": Object {},

View file

@ -20,22 +20,20 @@
import Hapi from 'hapi';
import { createMockServer } from './_mock_server';
import { createFindRoute } from './find';
import { SavedObjectsClientMock } from '../../../../core/server/mocks';
describe('GET /api/saved_objects/_find', () => {
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(),
};
const savedObjectsClient = SavedObjectsClientMock.create();
const clientResponse = {
total: 0,
saved_objects: [],
per_page: 0,
page: 0,
};
beforeEach(() => {
savedObjectsClient.find.mockImplementation(() => Promise.resolve(''));
savedObjectsClient.find.mockImplementation(() => Promise.resolve(clientResponse));
server = createMockServer();
const prereqs = {
@ -76,15 +74,18 @@ describe('GET /api/saved_objects/_find', () => {
url: '/api/saved_objects/_find?type=index-pattern',
};
const clientResponse = {
const findResponse = {
total: 2,
data: [
per_page: 2,
page: 1,
saved_objects: [
{
type: 'index-pattern',
id: 'logstash-*',
title: 'logstash-*',
timeFieldName: '@timestamp',
notExpandable: true,
attributes: {},
references: [],
},
{
@ -93,18 +94,19 @@ describe('GET /api/saved_objects/_find', () => {
title: 'stocks-*',
timeFieldName: '@timestamp',
notExpandable: true,
attributes: {},
references: [],
},
],
};
savedObjectsClient.find.mockImplementation(() => Promise.resolve(clientResponse));
savedObjectsClient.find.mockImplementation(() => Promise.resolve(findResponse));
const { payload, statusCode } = await server.inject(request);
const response = JSON.parse(payload);
expect(statusCode).toBe(200);
expect(response).toEqual(clientResponse);
expect(response).toEqual(findResponse);
});
it('calls upon savedObjectClient.find with defaults', async () => {

View file

@ -20,22 +20,24 @@
import Hapi from 'hapi';
import { createMockServer } from './_mock_server';
import { createGetRoute } from './get';
import { SavedObjectsClientMock } from '../../../../core/server/mocks';
describe('GET /api/saved_objects/{type}/{id}', () => {
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(),
};
const savedObjectsClient = SavedObjectsClientMock.create();
beforeEach(() => {
savedObjectsClient.get.mockImplementation(() => Promise.resolve(''));
savedObjectsClient.get.mockImplementation(() =>
Promise.resolve({
id: 'logstash-*',
title: 'logstash-*',
type: 'logstash-type',
attributes: {},
timeFieldName: '@timestamp',
notExpandable: true,
references: [],
})
);
server = createMockServer();
const prereqs = {
@ -62,6 +64,8 @@ describe('GET /api/saved_objects/{type}/{id}', () => {
const clientResponse = {
id: 'logstash-*',
title: 'logstash-*',
type: 'logstash-type',
attributes: {},
timeFieldName: '@timestamp',
notExpandable: true,
references: [],

View file

@ -20,18 +20,16 @@
import Hapi from 'hapi';
import { createMockServer } from './_mock_server';
import { createImportRoute } from './import';
import { SavedObjectsClientMock } from '../../../../core/server/mocks';
describe('POST /api/saved_objects/_import', () => {
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(),
const savedObjectsClient = SavedObjectsClientMock.create();
const emptyResponse = {
saved_objects: [],
total: 0,
per_page: 0,
page: 0,
};
beforeEach(() => {
@ -68,7 +66,7 @@ describe('POST /api/saved_objects/_import', () => {
'content-Type': 'multipart/form-data; boundary=BOUNDARY',
},
};
savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] });
savedObjectsClient.find.mockResolvedValueOnce(emptyResponse);
const { payload, statusCode } = await server.inject(request);
const response = JSON.parse(payload);
expect(statusCode).toBe(200);
@ -95,7 +93,7 @@ describe('POST /api/saved_objects/_import', () => {
'content-Type': 'multipart/form-data; boundary=EXAMPLE',
},
};
savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] });
savedObjectsClient.find.mockResolvedValueOnce(emptyResponse);
savedObjectsClient.bulkCreate.mockResolvedValueOnce({
saved_objects: [
{
@ -104,6 +102,7 @@ describe('POST /api/saved_objects/_import', () => {
attributes: {
title: 'my-pattern-*',
},
references: [],
},
],
});
@ -141,7 +140,7 @@ describe('POST /api/saved_objects/_import', () => {
'content-Type': 'multipart/form-data; boundary=EXAMPLE',
},
};
savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] });
savedObjectsClient.find.mockResolvedValueOnce(emptyResponse);
savedObjectsClient.bulkCreate.mockResolvedValueOnce({
saved_objects: [
{
@ -150,6 +149,7 @@ describe('POST /api/saved_objects/_import', () => {
attributes: {
title: 'my-pattern-*',
},
references: [],
},
{
type: 'dashboard',
@ -157,6 +157,7 @@ describe('POST /api/saved_objects/_import', () => {
attributes: {
title: 'Look at my dashboard',
},
references: [],
},
],
});
@ -187,7 +188,7 @@ describe('POST /api/saved_objects/_import', () => {
'content-Type': 'multipart/form-data; boundary=EXAMPLE',
},
};
savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] });
savedObjectsClient.find.mockResolvedValueOnce(emptyResponse);
savedObjectsClient.bulkCreate.mockResolvedValueOnce({
saved_objects: [
{
@ -256,6 +257,8 @@ describe('POST /api/saved_objects/_import', () => {
statusCode: 404,
message: 'Not found',
},
references: [],
attributes: {},
},
],
});

View file

@ -27,4 +27,5 @@ export { createImportRoute } from './import';
export { createLogLegacyImportRoute } from './log_legacy_import';
export { createResolveImportErrorsRoute } from './resolve_import_errors';
export { createUpdateRoute } from './update';
export { createBulkUpdateRoute } from './bulk_update';
export { createExportRoute } from './export';

View file

@ -20,19 +20,11 @@
import Hapi from 'hapi';
import { createMockServer } from './_mock_server';
import { createResolveImportErrorsRoute } from './resolve_import_errors';
import { SavedObjectsClientMock } from '../../../../core/server/mocks';
describe('POST /api/saved_objects/_resolve_import_errors', () => {
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(),
};
const savedObjectsClient = SavedObjectsClientMock.create();
beforeEach(() => {
server = createMockServer();
@ -111,6 +103,7 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => {
attributes: {
title: 'Look at my dashboard',
},
references: [],
},
],
});
@ -153,6 +146,7 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => {
attributes: {
title: 'Look at my dashboard',
},
references: [],
},
],
});
@ -219,6 +213,7 @@ describe('POST /api/saved_objects/_resolve_import_errors', () => {
attributes: {
title: 'Look at my dashboard',
},
references: [],
},
],
});

View file

@ -20,22 +20,23 @@
import Hapi from 'hapi';
import { createMockServer } from './_mock_server';
import { createUpdateRoute } from './update';
import { SavedObjectsClientMock } from '../../../../core/server/mocks';
describe('PUT /api/saved_objects/{type}/{id?}', () => {
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(),
};
const savedObjectsClient = SavedObjectsClientMock.create();
beforeEach(() => {
savedObjectsClient.update.mockImplementation(() => Promise.resolve(''));
const clientResponse = {
id: 'logstash-*',
title: 'logstash-*',
type: 'logstash-type',
attributes: {},
timeFieldName: '@timestamp',
notExpandable: true,
references: [],
};
savedObjectsClient.update.mockImplementation(() => Promise.resolve(clientResponse));
server = createMockServer();
const prereqs = {
@ -69,8 +70,10 @@ describe('PUT /api/saved_objects/{type}/{id?}', () => {
const clientResponse = {
id: 'logstash-*',
title: 'logstash-*',
type: 'logstash-type',
timeFieldName: '@timestamp',
notExpandable: true,
attributes: {},
references: [],
};

View file

@ -39,6 +39,7 @@ import {
createFindRoute,
createGetRoute,
createUpdateRoute,
createBulkUpdateRoute,
createExportRoute,
createImportRoute,
createResolveImportErrorsRoute,
@ -87,6 +88,7 @@ export async function savedObjectsMixin(kbnServer, server) {
};
server.route(createBulkCreateRoute(prereqs));
server.route(createBulkGetRoute(prereqs));
server.route(createBulkUpdateRoute(prereqs));
server.route(createCreateRoute(prereqs));
server.route(createDeleteRoute(prereqs));
server.route(createFindRoute(prereqs));

View file

@ -157,9 +157,9 @@ describe('Saved Objects Mixin', () => {
});
describe('Routes', () => {
it('should create 11 routes', () => {
it('should create 12 routes', () => {
savedObjectsMixin(mockKbnServer, mockServer);
expect(mockServer.route).toHaveBeenCalledTimes(11);
expect(mockServer.route).toHaveBeenCalledTimes(12);
});
it('should add POST /api/saved_objects/_bulk_create', () => {
savedObjectsMixin(mockKbnServer, mockServer);

View file

@ -0,0 +1,274 @@
/*
* 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 '@kbn/expect';
import _ from 'lodash';
export default function ({ getService }) {
const supertest = getService('supertest');
const es = getService('es');
const esArchiver = getService('esArchiver');
describe('bulkUpdate', () => {
describe('with kibana index', () => {
before(() => esArchiver.load('saved_objects/basic'));
after(() => esArchiver.unload('saved_objects/basic'));
it('should return 200', async () => {
const response = await supertest
.put(`/api/saved_objects/_bulk_update`)
.send([
{
type: 'visualization',
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
attributes: {
title: 'An existing visualization'
}
},
{
type: 'dashboard',
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
attributes: {
title: 'An existing dashboard'
}
},
])
.expect(200);
const { saved_objects: [ firstObject, secondObject ] } = response.body;
// loose ISO8601 UTC time with milliseconds validation
expect(firstObject).to.have.property('updated_at').match(/^[\d-]{10}T[\d:\.]{12}Z$/);
expect(_.omit(firstObject, ['updated_at'])).to.eql({
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
type: 'visualization',
version: 'WzgsMV0=',
attributes: {
title: 'An existing visualization',
},
});
expect(secondObject).to.have.property('updated_at').match(/^[\d-]{10}T[\d:\.]{12}Z$/);
expect(_.omit(secondObject, ['updated_at'])).to.eql({
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
type: 'dashboard',
version: 'WzksMV0=',
attributes: {
title: 'An existing dashboard',
},
});
});
it('does not pass references if omitted', async () => {
const { body: { saved_objects: [ visObject, dashObject ] } } = await supertest
.post(`/api/saved_objects/_bulk_get`)
.send([
{
type: 'visualization',
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
},
{
type: 'dashboard',
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
}
]);
const response = await supertest
.put(`/api/saved_objects/_bulk_update`)
.send([
{
type: 'visualization',
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
attributes: {
title: 'Changed title but nothing else'
},
version: visObject.version
},
{
type: 'dashboard',
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
attributes: {
title: 'Changed title and references'
},
version: dashObject.version,
references: [{ id: 'foo', name: 'Foo', type: 'visualization' }]
},
])
.expect(200);
const { saved_objects: [ firstUpdatedObject, secondUpdatedObject ] } = response.body;
expect(firstUpdatedObject).to.not.have.property('error');
expect(secondUpdatedObject).to.not.have.property('error');
const { body: { saved_objects: [ visObjectAfterUpdate, dashObjectAfterUpdate ] } } = await supertest
.post(`/api/saved_objects/_bulk_get`)
.send([
{
type: 'visualization',
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
},
{
type: 'dashboard',
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
}
]);
expect(visObjectAfterUpdate.references).to.eql(visObject.references);
expect(dashObjectAfterUpdate.references).to.eql([{ id: 'foo', name: 'Foo', type: 'visualization' }]);
});
it('passes empty references array if empty references array is provided', async () => {
const { body: { saved_objects: [ { version } ] } } = await supertest
.post(`/api/saved_objects/_bulk_get`)
.send([
{
type: 'visualization',
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
}
]);
await supertest
.put(`/api/saved_objects/_bulk_update`)
.send([
{
type: 'visualization',
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
attributes: {
title: 'Changed title but nothing else'
},
version,
references: []
}
])
.expect(200);
const { body: { saved_objects: [ visObjectAfterUpdate ] } } = await supertest
.post(`/api/saved_objects/_bulk_get`)
.send([
{
type: 'visualization',
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
}
]);
expect(visObjectAfterUpdate.references).to.eql([]);
});
describe('unknown id', () => {
it('should return a generic 404', async () => {
const response = await supertest
.put(`/api/saved_objects/_bulk_update`)
.send([
{
type: 'visualization',
id: 'not an id',
attributes: {
title: 'An existing visualization'
}
},
{
type: 'dashboard',
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
attributes: {
title: 'An existing dashboard'
}
},
])
.expect(200);
const { saved_objects: [ missingObject, updatedObject ] } = response.body;
// loose ISO8601 UTC time with milliseconds validation
expect(missingObject).eql({
type: 'visualization',
id: 'not an id',
error: {
statusCode: 404,
error: 'Not Found',
message: 'Saved object [visualization/not an id] not found'
}
});
expect(updatedObject).to.have.property('updated_at').match(/^[\d-]{10}T[\d:\.]{12}Z$/);
expect(_.omit(updatedObject, ['updated_at', 'version'])).to.eql({
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
type: 'dashboard',
attributes: {
title: 'An existing dashboard',
},
});
});
});
});
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 generic 404', async () => {
const response = await supertest
.put(`/api/saved_objects/_bulk_update`)
.send([
{
type: 'visualization',
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
attributes: {
title: 'An existing visualization'
}
},
{
type: 'dashboard',
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
attributes: {
title: 'An existing dashboard'
}
},
])
.expect(200);
const { saved_objects: [ firstObject, secondObject ] } = response.body;
expect(firstObject).to.eql({
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
type: 'visualization',
error: {
statusCode: 404,
error: 'Not Found',
message: 'Saved object [visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab] not found'
},
});
expect(secondObject).to.eql({
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
type: 'dashboard',
error: {
statusCode: 404,
error: 'Not Found',
message: 'Saved object [dashboard/be3733a0-9efe-11e7-acb3-3dab96693fab] not found'
},
});
});
});
});
}

View file

@ -29,6 +29,7 @@ export default function ({ loadTestFile }) {
loadTestFile(require.resolve('./import'));
loadTestFile(require.resolve('./resolve_import_errors'));
loadTestFile(require.resolve('./update'));
loadTestFile(require.resolve('./bulk_update'));
loadTestFile(require.resolve('./migrations'));
});
}

View file

@ -318,6 +318,194 @@ describe('#bulkCreate', () => {
});
});
describe('#bulkUpdate', () => {
it('redirects request to underlying base client if type is not registered', async () => {
const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
const mockedResponse = {
saved_objects: [{ id: 'some-id', type: 'unknown-type', attributes, references: [] }],
};
mockBaseClient.bulkUpdate.mockResolvedValue(mockedResponse);
await expect(
wrapper.bulkUpdate(
[{ type: 'unknown-type', id: 'some-id', attributes, version: 'some-version' }],
{}
)
).resolves.toEqual(mockedResponse);
expect(mockBaseClient.bulkUpdate).toHaveBeenCalledTimes(1);
expect(mockBaseClient.bulkUpdate).toHaveBeenCalledWith(
[{ type: 'unknown-type', id: 'some-id', attributes, version: 'some-version' }],
{}
);
});
it('encrypts attributes and strips them from response', async () => {
const docs = [
{
id: 'some-id',
type: 'known-type',
attributes: {
attrOne: 'one',
attrSecret: 'secret',
attrThree: 'three',
},
},
{
id: 'some-id-2',
type: 'known-type',
attributes: {
attrOne: 'one 2',
attrSecret: 'secret 2',
attrThree: 'three 2',
},
},
];
const mockedResponse = {
saved_objects: docs.map(doc => ({ ...doc, references: undefined })),
};
mockBaseClient.bulkUpdate.mockResolvedValue(mockedResponse);
await expect(wrapper.bulkUpdate(docs.map(doc => ({ ...doc })), {})).resolves.toEqual({
saved_objects: [
{
id: 'some-id',
type: 'known-type',
attributes: {
attrOne: 'one',
attrThree: 'three',
},
},
{
id: 'some-id-2',
type: 'known-type',
attributes: {
attrOne: 'one 2',
attrThree: 'three 2',
},
},
],
});
expect(encryptedSavedObjectsServiceMock.encryptAttributes).toHaveBeenCalledTimes(2);
expect(encryptedSavedObjectsServiceMock.encryptAttributes).toHaveBeenCalledWith(
{ type: 'known-type', id: 'some-id' },
{ attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }
);
expect(encryptedSavedObjectsServiceMock.encryptAttributes).toHaveBeenCalledWith(
{ type: 'known-type', id: 'some-id-2' },
{ attrOne: 'one 2', attrSecret: 'secret 2', attrThree: 'three 2' }
);
expect(mockBaseClient.bulkUpdate).toHaveBeenCalledTimes(1);
expect(mockBaseClient.bulkUpdate).toHaveBeenCalledWith(
[
{
id: 'some-id',
type: 'known-type',
attributes: {
attrOne: 'one',
attrSecret: '*secret*',
attrThree: 'three',
},
},
{
id: 'some-id-2',
type: 'known-type',
attributes: {
attrOne: 'one 2',
attrSecret: '*secret 2*',
attrThree: 'three 2',
},
},
],
{}
);
});
it('uses `namespace` to encrypt attributes if it is specified', async () => {
const docs = [
{
id: 'some-id',
type: 'known-type',
attributes: {
attrOne: 'one',
attrSecret: 'secret',
attrThree: 'three',
},
version: 'some-version',
},
];
mockBaseClient.bulkUpdate.mockResolvedValue({
saved_objects: docs.map(doc => ({ ...doc, references: undefined })),
});
await expect(wrapper.bulkUpdate(docs, { namespace: 'some-namespace' })).resolves.toEqual({
saved_objects: [
{
id: 'some-id',
type: 'known-type',
attributes: {
attrOne: 'one',
attrThree: 'three',
},
version: 'some-version',
references: undefined,
},
],
});
expect(encryptedSavedObjectsServiceMock.encryptAttributes).toHaveBeenCalledTimes(1);
expect(encryptedSavedObjectsServiceMock.encryptAttributes).toHaveBeenCalledWith(
{ type: 'known-type', id: 'some-id', namespace: 'some-namespace' },
{ attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }
);
expect(mockBaseClient.bulkUpdate).toHaveBeenCalledTimes(1);
expect(mockBaseClient.bulkUpdate).toHaveBeenCalledWith(
[
{
id: 'some-id',
type: 'known-type',
attributes: {
attrOne: 'one',
attrSecret: '*secret*',
attrThree: 'three',
},
version: 'some-version',
references: undefined,
},
],
{ namespace: 'some-namespace' }
);
});
it('fails if base client fails', async () => {
const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
const failureReason = new Error('Something bad happened...');
mockBaseClient.bulkUpdate.mockRejectedValue(failureReason);
await expect(
wrapper.bulkUpdate(
[{ type: 'unknown-type', id: 'some-id', attributes, version: 'some-version' }],
{}
)
).rejects.toThrowError(failureReason);
expect(mockBaseClient.bulkUpdate).toHaveBeenCalledTimes(1);
expect(mockBaseClient.bulkUpdate).toHaveBeenCalledWith(
[{ type: 'unknown-type', id: 'some-id', attributes, version: 'some-version' }],
{}
);
});
});
describe('#delete', () => {
it('redirects request to underlying base client if type is not registered', async () => {
const options = { namespace: 'some-ns' };

View file

@ -11,7 +11,9 @@ import {
SavedObjectsBaseOptions,
SavedObjectsBulkCreateObject,
SavedObjectsBulkGetObject,
SavedObjectsBulkUpdateObject,
SavedObjectsBulkResponse,
SavedObjectsBulkUpdateResponse,
SavedObjectsClientContract,
SavedObjectsCreateOptions,
SavedObjectsFindOptions,
@ -110,6 +112,34 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
);
}
public async bulkUpdate(
objects: SavedObjectsBulkUpdateObject[],
options?: SavedObjectsBaseOptions
) {
// We encrypt attributes for every object in parallel and that can potentially exhaust libuv or
// NodeJS thread pool. If it turns out to be a problem, we can consider switching to the
// sequential processing.
const encryptedObjects = await Promise.all(
objects.map(async object => {
const { type, id, attributes } = object;
if (!this.options.service.isRegistered(type)) {
return object;
}
return {
...object,
attributes: await this.options.service.encryptAttributes(
{ type, id, namespace: options && options.namespace },
attributes
),
};
})
);
return this.stripEncryptedAttributesFromBulkResponse(
await this.options.baseClient.bulkUpdate(encryptedObjects, options)
);
}
public async delete(type: string, id: string, options?: SavedObjectsBaseOptions) {
return await this.options.baseClient.delete(type, id, options);
}
@ -182,7 +212,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
* @param response Raw response returned by the underlying base client.
*/
private stripEncryptedAttributesFromBulkResponse<
T extends SavedObjectsBulkResponse | SavedObjectsFindResponse
T extends SavedObjectsBulkResponse | SavedObjectsFindResponse | SavedObjectsBulkUpdateResponse
>(response: T): T {
for (const savedObject of response.saved_objects) {
if (this.options.service.isRegistered(savedObject.type)) {

View file

@ -9,7 +9,7 @@ import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugin
import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder';
const readOperations: string[] = ['bulk_get', 'get', 'find'];
const writeOperations: string[] = ['create', 'bulk_create', 'update', 'delete'];
const writeOperations: string[] = ['create', 'bulk_create', 'update', 'bulk_update', 'delete'];
const allOperations: string[] = [...readOperations, ...writeOperations];
export class FeaturePrivilegeSavedObjectBuilder extends BaseFeaturePrivilegeBuilder {

View file

@ -191,6 +191,7 @@ describe('features', () => {
actions.savedObject.get('all-savedObject-all-1', 'create'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_create'),
actions.savedObject.get('all-savedObject-all-1', 'update'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_update'),
actions.savedObject.get('all-savedObject-all-1', 'delete'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_get'),
actions.savedObject.get('all-savedObject-all-2', 'get'),
@ -198,6 +199,7 @@ describe('features', () => {
actions.savedObject.get('all-savedObject-all-2', 'create'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_create'),
actions.savedObject.get('all-savedObject-all-2', 'update'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_update'),
actions.savedObject.get('all-savedObject-all-2', 'delete'),
actions.savedObject.get('all-savedObject-read-1', 'bulk_get'),
actions.savedObject.get('all-savedObject-read-1', 'get'),
@ -218,6 +220,7 @@ describe('features', () => {
actions.savedObject.get('read-savedObject-all-1', 'create'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_create'),
actions.savedObject.get('read-savedObject-all-1', 'update'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-1', 'delete'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_get'),
actions.savedObject.get('read-savedObject-all-2', 'get'),
@ -225,6 +228,7 @@ describe('features', () => {
actions.savedObject.get('read-savedObject-all-2', 'create'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_create'),
actions.savedObject.get('read-savedObject-all-2', 'update'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-2', 'delete'),
actions.savedObject.get('read-savedObject-read-1', 'bulk_get'),
actions.savedObject.get('read-savedObject-read-1', 'get'),
@ -427,6 +431,7 @@ describe('features', () => {
actions.savedObject.get('bar-savedObject-all-1', 'create'),
actions.savedObject.get('bar-savedObject-all-1', 'bulk_create'),
actions.savedObject.get('bar-savedObject-all-1', 'update'),
actions.savedObject.get('bar-savedObject-all-1', 'bulk_update'),
actions.savedObject.get('bar-savedObject-all-1', 'delete'),
actions.savedObject.get('bar-savedObject-all-2', 'bulk_get'),
actions.savedObject.get('bar-savedObject-all-2', 'get'),
@ -434,6 +439,7 @@ describe('features', () => {
actions.savedObject.get('bar-savedObject-all-2', 'create'),
actions.savedObject.get('bar-savedObject-all-2', 'bulk_create'),
actions.savedObject.get('bar-savedObject-all-2', 'update'),
actions.savedObject.get('bar-savedObject-all-2', 'bulk_update'),
actions.savedObject.get('bar-savedObject-all-2', 'delete'),
actions.savedObject.get('bar-savedObject-read-1', 'bulk_get'),
actions.savedObject.get('bar-savedObject-read-1', 'get'),
@ -453,6 +459,7 @@ describe('features', () => {
actions.savedObject.get('all-savedObject-all-1', 'create'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_create'),
actions.savedObject.get('all-savedObject-all-1', 'update'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_update'),
actions.savedObject.get('all-savedObject-all-1', 'delete'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_get'),
actions.savedObject.get('all-savedObject-all-2', 'get'),
@ -460,6 +467,7 @@ describe('features', () => {
actions.savedObject.get('all-savedObject-all-2', 'create'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_create'),
actions.savedObject.get('all-savedObject-all-2', 'update'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_update'),
actions.savedObject.get('all-savedObject-all-2', 'delete'),
actions.savedObject.get('all-savedObject-read-1', 'bulk_get'),
actions.savedObject.get('all-savedObject-read-1', 'get'),
@ -479,6 +487,7 @@ describe('features', () => {
actions.savedObject.get('read-savedObject-all-1', 'create'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_create'),
actions.savedObject.get('read-savedObject-all-1', 'update'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-1', 'delete'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_get'),
actions.savedObject.get('read-savedObject-all-2', 'get'),
@ -486,6 +495,7 @@ describe('features', () => {
actions.savedObject.get('read-savedObject-all-2', 'create'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_create'),
actions.savedObject.get('read-savedObject-all-2', 'update'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-2', 'delete'),
actions.savedObject.get('read-savedObject-read-1', 'bulk_get'),
actions.savedObject.get('read-savedObject-read-1', 'get'),
@ -570,6 +580,7 @@ describe('features', () => {
actions.savedObject.get('read-savedObject-all-1', 'create'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_create'),
actions.savedObject.get('read-savedObject-all-1', 'update'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-1', 'delete'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_get'),
actions.savedObject.get('read-savedObject-all-2', 'get'),
@ -577,6 +588,7 @@ describe('features', () => {
actions.savedObject.get('read-savedObject-all-2', 'create'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_create'),
actions.savedObject.get('read-savedObject-all-2', 'update'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-2', 'delete'),
actions.savedObject.get('read-savedObject-read-1', 'bulk_get'),
actions.savedObject.get('read-savedObject-read-1', 'get'),
@ -920,6 +932,7 @@ describe('reserved', () => {
actions.savedObject.get('savedObject-all-1', 'create'),
actions.savedObject.get('savedObject-all-1', 'bulk_create'),
actions.savedObject.get('savedObject-all-1', 'update'),
actions.savedObject.get('savedObject-all-1', 'bulk_update'),
actions.savedObject.get('savedObject-all-1', 'delete'),
actions.savedObject.get('savedObject-all-2', 'bulk_get'),
actions.savedObject.get('savedObject-all-2', 'get'),
@ -927,6 +940,7 @@ describe('reserved', () => {
actions.savedObject.get('savedObject-all-2', 'create'),
actions.savedObject.get('savedObject-all-2', 'bulk_create'),
actions.savedObject.get('savedObject-all-2', 'update'),
actions.savedObject.get('savedObject-all-2', 'bulk_update'),
actions.savedObject.get('savedObject-all-2', 'delete'),
actions.savedObject.get('savedObject-read-1', 'bulk_get'),
actions.savedObject.get('savedObject-read-1', 'get'),

View file

@ -105,6 +105,18 @@ export class SecureSavedObjectsClientWrapper {
return await this._baseClient.update(type, id, attributes, options);
}
async bulkUpdate(objects = [], options) {
const types = uniq(objects.map(o => o.type));
await this._ensureAuthorized(
types,
'bulk_update',
options && options.namespace,
{ objects, options },
);
return await this._baseClient.bulkUpdate(objects, options);
}
async _checkPrivileges(actions, namespace) {
try {
return await this._checkSavedObjectsPrivileges(actions, namespace);

View file

@ -995,4 +995,145 @@ describe(`spaces disabled`, () => {
});
});
});
describe('#bulkUpdate', () => {
test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => {
const type = 'foo';
const mockErrors = createMockErrors();
const mockCheckPrivileges = jest.fn(async () => {
throw new Error('An actual error would happen here');
});
const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges);
const mockRequest = Symbol();
const mockAuditLogger = createMockAuditLogger();
const mockActions = createMockActions();
const client = new SecureSavedObjectsClientWrapper({
actions: mockActions,
auditLogger: mockAuditLogger,
baseClient: null,
checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest,
errors: mockErrors,
request: mockRequest,
savedObjectTypes: [],
spaces: null,
});
const objects = [{
type
}];
await expect(
client.bulkUpdate(objects)
).rejects.toThrowError(mockErrors.generalError);
expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest);
expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'bulk_update')], undefined);
expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1);
expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled();
expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled();
});
test(`throws decorated ForbiddenError when unauthorized`, async () => {
const type = 'foo';
const username = Symbol();
const mockActions = createMockActions();
const mockErrors = createMockErrors();
const mockCheckPrivileges = jest.fn(async () => ({
hasAllRequested: false,
username,
privileges: {
[mockActions.savedObject.get(type, 'bulk_update')]: false,
}
}));
const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges);
const mockRequest = Symbol();
const mockAuditLogger = createMockAuditLogger();
const client = new SecureSavedObjectsClientWrapper({
actions: mockActions,
auditLogger: mockAuditLogger,
baseClient: null,
checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest,
errors: mockErrors,
request: mockRequest,
savedObjectTypes: [],
spaces: null,
});
const id = Symbol();
const attributes = Symbol();
const namespace = Symbol();
await expect(
client.bulkUpdate([{ type, id, attributes }], { namespace })
).rejects.toThrowError(mockErrors.forbiddenError);
expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest);
expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'bulk_update')], namespace);
expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1);
expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith(
username,
'bulk_update',
[type],
[mockActions.savedObject.get(type, 'bulk_update')],
{
objects: [
{
type,
id,
attributes,
}
],
options: { namespace }
}
);
expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled();
});
test(`returns result of baseClient.bulkUpdate when authorized`, async () => {
const type = 'foo';
const username = Symbol();
const returnValue = Symbol();
const mockActions = createMockActions();
const mockBaseClient = {
bulkUpdate: jest.fn().mockReturnValue(returnValue)
};
const mockCheckPrivileges = jest.fn(async () => ({
hasAllRequested: true,
username,
privileges: {
[mockActions.savedObject.get(type, 'bulkUpdate')]: true,
}
}));
const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges);
const mockRequest = Symbol();
const mockAuditLogger = createMockAuditLogger();
const client = new SecureSavedObjectsClientWrapper({
actions: mockActions,
auditLogger: mockAuditLogger,
baseClient: mockBaseClient,
checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest,
errors: null,
request: mockRequest,
savedObjectTypes: [],
spaces: null,
});
const id = Symbol();
const attributes = Symbol();
const namespace = Symbol();
const result = await client.bulkUpdate([{ type, id, attributes }], { namespace });
expect(result).toBe(returnValue);
expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest);
expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'bulk_update')], namespace);
expect(mockBaseClient.bulkUpdate).toHaveBeenCalledWith([{ type, id, attributes }], { namespace });
expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled();
expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_update', [type], {
objects: [{
type,
id,
attributes,
}],
options: { namespace }
});
});
});
});

View file

@ -7,30 +7,28 @@
import { DEFAULT_SPACE_ID } from '../../../common/constants';
import { SpacesSavedObjectsClient } from './spaces_saved_objects_client';
import { spacesServiceMock } from '../../spaces_service/spaces_service.mock';
import { SavedObjectsClientMock } from '../../../../../../src/core/server/mocks';
const types = ['foo', 'bar', 'space'];
const createMockRequest = () => ({});
const createMockClient = () => {
const errors = Symbol() as any;
return {
get: jest.fn(),
bulkGet: jest.fn(),
find: jest.fn(),
create: jest.fn(),
bulkCreate: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
errors,
};
};
const createMockClient = () => SavedObjectsClientMock.create();
const createSpacesService = async (spaceId: string) => {
return spacesServiceMock.createSetupContract(spaceId);
};
const createMockResponse = () => ({
id: 'logstash-*',
title: 'logstash-*',
type: 'logstash-type',
attributes: {},
timeFieldName: '@timestamp',
notExpandable: true,
references: [],
});
[
{ id: DEFAULT_SPACE_ID, expectedNamespace: undefined },
{ id: 'space_1', expectedNamespace: 'space_1' },
@ -57,8 +55,8 @@ const createSpacesService = async (spaceId: string) => {
test(`supplements options with undefined namespace`, async () => {
const request = createMockRequest();
const baseClient = createMockClient();
const expectedReturnValue = Symbol();
baseClient.get.mockReturnValue(expectedReturnValue);
const expectedReturnValue = createMockResponse();
baseClient.get.mockReturnValue(Promise.resolve(expectedReturnValue));
const spacesService = await createSpacesService(currentSpace.id);
const client = new SpacesSavedObjectsClient({
@ -102,8 +100,10 @@ const createSpacesService = async (spaceId: string) => {
test(`supplements options with undefined namespace`, async () => {
const request = createMockRequest();
const baseClient = createMockClient();
const expectedReturnValue = Symbol();
baseClient.bulkGet.mockReturnValue(expectedReturnValue);
const expectedReturnValue = {
saved_objects: [createMockResponse()],
};
baseClient.bulkGet.mockReturnValue(Promise.resolve(expectedReturnValue));
const spacesService = await createSpacesService(currentSpace.id);
const client = new SpacesSavedObjectsClient({
@ -147,8 +147,13 @@ const createSpacesService = async (spaceId: string) => {
test(`passes options.type to baseClient if valid singular type specified`, async () => {
const request = createMockRequest();
const baseClient = createMockClient();
const expectedReturnValue = Symbol();
baseClient.find.mockReturnValue(expectedReturnValue);
const expectedReturnValue = {
saved_objects: [createMockResponse()],
total: 1,
per_page: 0,
page: 0,
};
baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue));
const spacesService = await createSpacesService(currentSpace.id);
const client = new SpacesSavedObjectsClient({
@ -171,8 +176,13 @@ const createSpacesService = async (spaceId: string) => {
test(`supplements options with undefined namespace`, async () => {
const request = createMockRequest();
const baseClient = createMockClient();
const expectedReturnValue = Symbol();
baseClient.find.mockReturnValue(expectedReturnValue);
const expectedReturnValue = {
saved_objects: [createMockResponse()],
total: 1,
per_page: 0,
page: 0,
};
baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue));
const spacesService = await createSpacesService(currentSpace.id);
const client = new SpacesSavedObjectsClient({
@ -214,8 +224,8 @@ const createSpacesService = async (spaceId: string) => {
test(`supplements options with undefined namespace`, async () => {
const request = createMockRequest();
const baseClient = createMockClient();
const expectedReturnValue = Symbol();
baseClient.create.mockReturnValue(expectedReturnValue);
const expectedReturnValue = createMockResponse();
baseClient.create.mockReturnValue(Promise.resolve(expectedReturnValue));
const spacesService = await createSpacesService(currentSpace.id);
const client = new SpacesSavedObjectsClient({
@ -260,8 +270,10 @@ const createSpacesService = async (spaceId: string) => {
test(`supplements options with undefined namespace`, async () => {
const request = createMockRequest();
const baseClient = createMockClient();
const expectedReturnValue = Symbol();
baseClient.bulkCreate.mockReturnValue(expectedReturnValue);
const expectedReturnValue = {
saved_objects: [createMockResponse()],
};
baseClient.bulkCreate.mockReturnValue(Promise.resolve(expectedReturnValue));
const spacesService = await createSpacesService(currentSpace.id);
const client = new SpacesSavedObjectsClient({
@ -306,8 +318,8 @@ const createSpacesService = async (spaceId: string) => {
test(`supplements options with undefined namespace`, async () => {
const request = createMockRequest();
const baseClient = createMockClient();
const expectedReturnValue = Symbol();
baseClient.update.mockReturnValue(expectedReturnValue);
const expectedReturnValue = createMockResponse();
baseClient.update.mockReturnValue(Promise.resolve(expectedReturnValue));
const spacesService = await createSpacesService(currentSpace.id);
const client = new SpacesSavedObjectsClient({
@ -332,6 +344,42 @@ const createSpacesService = async (spaceId: string) => {
});
});
describe('#bulkUpdate', () => {
test(`supplements options with the spaces namespace`, async () => {
const request = createMockRequest();
const baseClient = createMockClient();
const expectedReturnValue = {
saved_objects: [createMockResponse()],
};
baseClient.bulkUpdate.mockReturnValue(Promise.resolve(expectedReturnValue));
const spacesService = await createSpacesService(currentSpace.id);
const client = new SpacesSavedObjectsClient({
request,
baseClient,
spacesService,
types,
});
const actualReturnValue = await client.bulkUpdate([
{ id: 'id', type: 'foo', attributes: {}, references: [] },
]);
expect(actualReturnValue).toBe(expectedReturnValue);
expect(baseClient.bulkUpdate).toHaveBeenCalledWith(
[
{
id: 'id',
type: 'foo',
attributes: {},
references: [],
},
],
{ namespace: currentSpace.expectedNamespace }
);
});
});
describe('#delete', () => {
test(`throws error if options.namespace is specified`, async () => {
const request = createMockRequest();
@ -354,8 +402,8 @@ const createSpacesService = async (spaceId: string) => {
test(`supplements options with undefined namespace`, async () => {
const request = createMockRequest();
const baseClient = createMockClient();
const expectedReturnValue = Symbol();
baseClient.delete.mockReturnValue(expectedReturnValue);
const expectedReturnValue = createMockResponse();
baseClient.delete.mockReturnValue(Promise.resolve(expectedReturnValue));
const spacesService = await createSpacesService(currentSpace.id);
const client = new SpacesSavedObjectsClient({

View file

@ -9,6 +9,7 @@ import {
SavedObjectsBaseOptions,
SavedObjectsBulkCreateObject,
SavedObjectsBulkGetObject,
SavedObjectsBulkUpdateObject,
SavedObjectsClientContract,
SavedObjectsCreateOptions,
SavedObjectsFindOptions,
@ -211,4 +212,27 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
namespace: spaceIdToNamespace(this.spaceId),
});
}
/**
* Updates an array of objects by id
*
* @param {array} objects - an array ids, or an array of objects containing id, type, attributes and optionally version, references and namespace
* @returns {promise} - { saved_objects: [{ id, type, version, attributes }] }
* @example
*
* bulkUpdate([
* { id: 'one', type: 'config', attributes: { title: 'My new title'}, version: 'd7rhfk47d=' },
* { id: 'foo', type: 'index-pattern', attributes: {} }
* ])
*/
public async bulkUpdate(
objects: SavedObjectsBulkUpdateObject[] = [],
options: SavedObjectsBaseOptions = {}
) {
throwErrorIfNamespaceSpecified(options);
return await this.client.bulkUpdate(objects, {
...options,
namespace: spaceIdToNamespace(this.spaceId),
});
}
}

View file

@ -0,0 +1,239 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { SuperTest } from 'supertest';
import { DEFAULT_SPACE_ID } from '../../../../legacy/plugins/spaces/common/constants';
import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils';
import { DescribeFn, TestDefinitionAuthentication } from '../lib/types';
interface BulkUpdateTest {
statusCode: number;
response: (resp: { [key: string]: any }) => void;
}
interface BulkUpdateTests {
spaceAware: BulkUpdateTest;
notSpaceAware: BulkUpdateTest;
hiddenType: BulkUpdateTest;
doesntExist: BulkUpdateTest;
}
interface BulkUpdateTestDefinition {
user?: TestDefinitionAuthentication;
spaceId?: string;
otherSpaceId?: string;
tests: BulkUpdateTests;
}
export function bulkUpdateTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>) {
const createExpectNotFound = (type: string, id: string, spaceId = DEFAULT_SPACE_ID) => (resp: {
[key: string]: any;
}) => {
const [, savedObject] = resp.body.saved_objects;
expect(savedObject.error).eql({
statusCode: 404,
error: 'Not Found',
message: `Saved object [${type}/${getIdPrefix(spaceId)}${id}] not found`,
});
};
const createExpectDoesntExistNotFound = (spaceId?: string) => {
return createExpectNotFound('visualization', 'not an id', spaceId);
};
const createExpectSpaceAwareNotFound = (spaceId?: string) => {
return createExpectNotFound('visualization', 'dd7caf20-9efd-11e7-acb3-3dab96693fab', spaceId);
};
const expectHiddenTypeNotFound = createExpectNotFound(
'hiddentype',
'hiddentype_1',
DEFAULT_SPACE_ID
);
const createExpectRbacForbidden = (types: string[]) => (resp: { [key: string]: any }) => {
expect(resp.body).to.eql({
statusCode: 403,
error: 'Forbidden',
message: `Unable to bulk_update ${types.join()}`,
});
};
const expectDoesntExistRbacForbidden = createExpectRbacForbidden(['globaltype', 'visualization']);
const expectNotSpaceAwareRbacForbidden = createExpectRbacForbidden(['globaltype']);
const expectHiddenTypeRbacForbidden = createExpectRbacForbidden(['globaltype', 'hiddentype']);
const expectHiddenTypeRbacForbiddenWithGlobalAllowed = createExpectRbacForbidden(['hiddentype']);
const expectSpaceAwareRbacForbidden = createExpectRbacForbidden(['globaltype', 'visualization']);
const expectNotSpaceAwareResults = (resp: { [key: string]: any }) => {
const [, savedObject] = resp.body.saved_objects;
// loose uuid validation
expect(savedObject)
.to.have.property('id')
.match(/^[0-9a-f-]{36}$/);
// loose ISO8601 UTC time with milliseconds validation
expect(savedObject)
.to.have.property('updated_at')
.match(/^[\d-]{10}T[\d:\.]{12}Z$/);
expect(savedObject).to.eql({
id: savedObject.id,
type: 'globaltype',
updated_at: savedObject.updated_at,
version: savedObject.version,
attributes: {
name: 'My second favorite',
},
});
};
const expectSpaceAwareResults = (resp: { [key: string]: any }) => {
const [, savedObject] = resp.body.saved_objects;
// loose uuid validation ignoring prefix
expect(savedObject)
.to.have.property('id')
.match(/[0-9a-f-]{36}$/);
// loose ISO8601 UTC time with milliseconds validation
expect(savedObject)
.to.have.property('updated_at')
.match(/^[\d-]{10}T[\d:\.]{12}Z$/);
expect(savedObject).to.eql({
id: savedObject.id,
type: 'visualization',
updated_at: savedObject.updated_at,
version: savedObject.version,
attributes: {
title: 'My second favorite vis',
},
});
};
const makeBulkUpdateTest = (describeFn: DescribeFn) => (
description: string,
definition: BulkUpdateTestDefinition
) => {
const { user = {}, spaceId = DEFAULT_SPACE_ID, otherSpaceId, tests } = definition;
// We add this type into all bulk updates
// to ensure that having additional items in the bulk
// update doesn't change the expected outcome overall
let updateCount = 0;
const generateNonSpaceAwareGlobalSavedObject = () => ({
type: 'globaltype',
id: `8121a00-8efd-21e7-1cb3-34ab966434445`,
attributes: {
name: `Update #${++updateCount}`,
},
});
describeFn(description, () => {
before(() => esArchiver.load('saved_objects/spaces'));
after(() => esArchiver.unload('saved_objects/spaces'));
it(`should return ${tests.spaceAware.statusCode} for a space-aware doc`, async () => {
await supertest
.put(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_update`)
.auth(user.username, user.password)
.send([
generateNonSpaceAwareGlobalSavedObject(),
{
type: 'visualization',
id: `${getIdPrefix(otherSpaceId || spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`,
attributes: {
title: 'My second favorite vis',
},
},
generateNonSpaceAwareGlobalSavedObject(),
])
.expect(tests.spaceAware.statusCode)
.then(tests.spaceAware.response);
});
it(`should return ${tests.notSpaceAware.statusCode} for a non space-aware doc`, async () => {
await supertest
.put(`${getUrlPrefix(otherSpaceId || spaceId)}/api/saved_objects/_bulk_update`)
.auth(user.username, user.password)
.send([
generateNonSpaceAwareGlobalSavedObject(),
{
type: 'globaltype',
id: `8121a00-8efd-21e7-1cb3-34ab966434445`,
attributes: {
name: 'My second favorite',
},
},
generateNonSpaceAwareGlobalSavedObject(),
])
.expect(tests.notSpaceAware.statusCode)
.then(tests.notSpaceAware.response);
});
it(`should return ${tests.hiddenType.statusCode} for hiddentype doc`, async () => {
await supertest
.put(`${getUrlPrefix(otherSpaceId || spaceId)}/api/saved_objects/_bulk_update`)
.auth(user.username, user.password)
.send([
generateNonSpaceAwareGlobalSavedObject(),
{
type: 'hiddentype',
id: 'hiddentype_1',
attributes: {
name: 'My favorite hidden type',
},
},
generateNonSpaceAwareGlobalSavedObject(),
])
.expect(tests.hiddenType.statusCode)
.then(tests.hiddenType.response);
});
describe('unknown id', () => {
it(`should return ${tests.doesntExist.statusCode}`, async () => {
await supertest
.put(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_update`)
.auth(user.username, user.password)
.send([
generateNonSpaceAwareGlobalSavedObject(),
{
type: 'visualization',
id: `${getIdPrefix(spaceId)}not an id`,
attributes: {
title: 'My second favorite vis',
},
},
generateNonSpaceAwareGlobalSavedObject(),
])
.expect(tests.doesntExist.statusCode)
.then(tests.doesntExist.response);
});
});
});
};
const bulkUpdateTest = makeBulkUpdateTest(describe);
// @ts-ignore
bulkUpdateTest.only = makeBulkUpdateTest(describe.only);
return {
createExpectDoesntExistNotFound,
createExpectSpaceAwareNotFound,
expectSpaceNotFound: expectHiddenTypeNotFound,
expectDoesntExistRbacForbidden,
expectNotSpaceAwareRbacForbidden,
expectNotSpaceAwareResults,
expectSpaceAwareRbacForbidden,
expectSpaceAwareResults,
expectHiddenTypeRbacForbidden,
expectHiddenTypeRbacForbiddenWithGlobalAllowed,
bulkUpdateTest,
};
}

View file

@ -0,0 +1,293 @@
/*
* 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 { FtrProviderContext } from '../../common/ftr_provider_context';
import { bulkUpdateTestSuiteFactory } from '../../common/suites/bulk_update';
export default function({ getService }: FtrProviderContext) {
const supertest = getService('supertestWithoutAuth');
const esArchiver = getService('esArchiver');
describe('bulkUpdate', () => {
const {
createExpectDoesntExistNotFound,
expectDoesntExistRbacForbidden,
expectNotSpaceAwareResults,
expectNotSpaceAwareRbacForbidden,
expectSpaceAwareRbacForbidden,
expectSpaceAwareResults,
expectSpaceNotFound,
expectHiddenTypeRbacForbidden,
expectHiddenTypeRbacForbiddenWithGlobalAllowed,
bulkUpdateTest,
} = bulkUpdateTestSuiteFactory(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 => {
bulkUpdateTest(`user with no access within the ${scenario.spaceId} space`, {
user: scenario.users.noAccess,
spaceId: scenario.spaceId,
tests: {
spaceAware: {
statusCode: 403,
response: expectSpaceAwareRbacForbidden,
},
notSpaceAware: {
statusCode: 403,
response: expectNotSpaceAwareRbacForbidden,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbidden,
},
doesntExist: {
statusCode: 403,
response: expectDoesntExistRbacForbidden,
},
},
});
bulkUpdateTest(`superuser within the ${scenario.spaceId} space`, {
user: scenario.users.superuser,
spaceId: scenario.spaceId,
tests: {
spaceAware: {
statusCode: 200,
response: expectSpaceAwareResults,
},
notSpaceAware: {
statusCode: 200,
response: expectNotSpaceAwareResults,
},
hiddenType: {
statusCode: 200,
response: expectSpaceNotFound,
},
doesntExist: {
statusCode: 200,
response: createExpectDoesntExistNotFound(scenario.spaceId),
},
},
});
bulkUpdateTest(`legacy user within the ${scenario.spaceId} space`, {
user: scenario.users.legacyAll,
spaceId: scenario.spaceId,
tests: {
spaceAware: {
statusCode: 403,
response: expectSpaceAwareRbacForbidden,
},
notSpaceAware: {
statusCode: 403,
response: expectNotSpaceAwareRbacForbidden,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbidden,
},
doesntExist: {
statusCode: 403,
response: expectDoesntExistRbacForbidden,
},
},
});
bulkUpdateTest(`dual-privileges user within the ${scenario.spaceId} space`, {
user: scenario.users.dualAll,
spaceId: scenario.spaceId,
tests: {
spaceAware: {
statusCode: 200,
response: expectSpaceAwareResults,
},
notSpaceAware: {
statusCode: 200,
response: expectNotSpaceAwareResults,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbiddenWithGlobalAllowed,
},
doesntExist: {
statusCode: 200,
response: createExpectDoesntExistNotFound(scenario.spaceId),
},
},
});
bulkUpdateTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, {
user: scenario.users.dualRead,
spaceId: scenario.spaceId,
tests: {
spaceAware: {
statusCode: 403,
response: expectSpaceAwareRbacForbidden,
},
notSpaceAware: {
statusCode: 403,
response: expectNotSpaceAwareRbacForbidden,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbidden,
},
doesntExist: {
statusCode: 403,
response: expectDoesntExistRbacForbidden,
},
},
});
bulkUpdateTest(`rbac user with all globally within the ${scenario.spaceId} space`, {
user: scenario.users.allGlobally,
spaceId: scenario.spaceId,
tests: {
spaceAware: {
statusCode: 200,
response: expectSpaceAwareResults,
},
notSpaceAware: {
statusCode: 200,
response: expectNotSpaceAwareResults,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbiddenWithGlobalAllowed,
},
doesntExist: {
statusCode: 200,
response: createExpectDoesntExistNotFound(scenario.spaceId),
},
},
});
bulkUpdateTest(`rbac user with read globally within the ${scenario.spaceId} space`, {
user: scenario.users.readGlobally,
spaceId: scenario.spaceId,
tests: {
spaceAware: {
statusCode: 403,
response: expectSpaceAwareRbacForbidden,
},
notSpaceAware: {
statusCode: 403,
response: expectNotSpaceAwareRbacForbidden,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbidden,
},
doesntExist: {
statusCode: 403,
response: expectDoesntExistRbacForbidden,
},
},
});
bulkUpdateTest(`rbac user with all at the space within the ${scenario.spaceId} space`, {
user: scenario.users.allAtSpace,
spaceId: scenario.spaceId,
tests: {
spaceAware: {
statusCode: 200,
response: expectSpaceAwareResults,
},
notSpaceAware: {
statusCode: 200,
response: expectNotSpaceAwareResults,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbiddenWithGlobalAllowed,
},
doesntExist: {
statusCode: 200,
response: createExpectDoesntExistNotFound(scenario.spaceId),
},
},
});
bulkUpdateTest(`rbac user with read at the space within the ${scenario.spaceId} space`, {
user: scenario.users.readAtSpace,
spaceId: scenario.spaceId,
tests: {
spaceAware: {
statusCode: 403,
response: expectSpaceAwareRbacForbidden,
},
notSpaceAware: {
statusCode: 403,
response: expectNotSpaceAwareRbacForbidden,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbidden,
},
doesntExist: {
statusCode: 403,
response: expectDoesntExistRbacForbidden,
},
},
});
bulkUpdateTest(`rbac user with all at other space within the ${scenario.spaceId} space`, {
user: scenario.users.allAtOtherSpace,
spaceId: scenario.spaceId,
tests: {
spaceAware: {
statusCode: 403,
response: expectSpaceAwareRbacForbidden,
},
notSpaceAware: {
statusCode: 403,
response: expectNotSpaceAwareRbacForbidden,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbidden,
},
doesntExist: {
statusCode: 403,
response: expectDoesntExistRbacForbidden,
},
},
});
});
});
}

View file

@ -28,5 +28,6 @@ export default function({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./import'));
loadTestFile(require.resolve('./resolve_import_errors'));
loadTestFile(require.resolve('./update'));
loadTestFile(require.resolve('./bulk_update'));
});
}

View file

@ -0,0 +1,271 @@
/*
* 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 { FtrProviderContext } from '../../common/ftr_provider_context';
import { bulkUpdateTestSuiteFactory } from '../../common/suites/bulk_update';
export default function({ getService }: FtrProviderContext) {
const supertest = getService('supertestWithoutAuth');
const esArchiver = getService('esArchiver');
describe('bulkUpdate', () => {
const {
createExpectDoesntExistNotFound,
expectDoesntExistRbacForbidden,
expectNotSpaceAwareResults,
expectNotSpaceAwareRbacForbidden,
expectSpaceAwareRbacForbidden,
expectSpaceAwareResults,
expectSpaceNotFound,
expectHiddenTypeRbacForbidden,
expectHiddenTypeRbacForbiddenWithGlobalAllowed,
bulkUpdateTest,
} = bulkUpdateTestSuiteFactory(esArchiver, supertest);
bulkUpdateTest(`user with no access`, {
user: AUTHENTICATION.NOT_A_KIBANA_USER,
tests: {
spaceAware: {
statusCode: 403,
response: expectSpaceAwareRbacForbidden,
},
notSpaceAware: {
statusCode: 403,
response: expectNotSpaceAwareRbacForbidden,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbidden,
},
doesntExist: {
statusCode: 403,
response: expectDoesntExistRbacForbidden,
},
},
});
bulkUpdateTest(`superuser`, {
user: AUTHENTICATION.SUPERUSER,
tests: {
spaceAware: {
statusCode: 200,
response: expectSpaceAwareResults,
},
notSpaceAware: {
statusCode: 200,
response: expectNotSpaceAwareResults,
},
hiddenType: {
statusCode: 200,
response: expectSpaceNotFound,
},
doesntExist: {
statusCode: 200,
response: createExpectDoesntExistNotFound(),
},
},
});
bulkUpdateTest(`legacy user`, {
user: AUTHENTICATION.KIBANA_LEGACY_USER,
tests: {
spaceAware: {
statusCode: 403,
response: expectSpaceAwareRbacForbidden,
},
notSpaceAware: {
statusCode: 403,
response: expectNotSpaceAwareRbacForbidden,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbidden,
},
doesntExist: {
statusCode: 403,
response: expectDoesntExistRbacForbidden,
},
},
});
bulkUpdateTest(`dual-privileges user`, {
user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER,
tests: {
spaceAware: {
statusCode: 200,
response: expectSpaceAwareResults,
},
notSpaceAware: {
statusCode: 200,
response: expectNotSpaceAwareResults,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbiddenWithGlobalAllowed,
},
doesntExist: {
statusCode: 200,
response: createExpectDoesntExistNotFound(),
},
},
});
bulkUpdateTest(`dual-privileges readonly user`, {
user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER,
tests: {
spaceAware: {
statusCode: 403,
response: expectSpaceAwareRbacForbidden,
},
notSpaceAware: {
statusCode: 403,
response: expectNotSpaceAwareRbacForbidden,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbidden,
},
doesntExist: {
statusCode: 403,
response: expectDoesntExistRbacForbidden,
},
},
});
bulkUpdateTest(`rbac user with all globally`, {
user: AUTHENTICATION.KIBANA_RBAC_USER,
tests: {
spaceAware: {
statusCode: 200,
response: expectSpaceAwareResults,
},
notSpaceAware: {
statusCode: 200,
response: expectNotSpaceAwareResults,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbiddenWithGlobalAllowed,
},
doesntExist: {
statusCode: 200,
response: createExpectDoesntExistNotFound(),
},
},
});
bulkUpdateTest(`rbac user with read globally`, {
user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER,
tests: {
spaceAware: {
statusCode: 403,
response: expectSpaceAwareRbacForbidden,
},
notSpaceAware: {
statusCode: 403,
response: expectNotSpaceAwareRbacForbidden,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbidden,
},
doesntExist: {
statusCode: 403,
response: expectDoesntExistRbacForbidden,
},
},
});
bulkUpdateTest(`rbac user with all at default space`, {
user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER,
tests: {
spaceAware: {
statusCode: 403,
response: expectSpaceAwareRbacForbidden,
},
notSpaceAware: {
statusCode: 403,
response: expectNotSpaceAwareRbacForbidden,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbidden,
},
doesntExist: {
statusCode: 403,
response: expectDoesntExistRbacForbidden,
},
},
});
bulkUpdateTest(`rbac user with read at default space`, {
user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER,
tests: {
spaceAware: {
statusCode: 403,
response: expectSpaceAwareRbacForbidden,
},
notSpaceAware: {
statusCode: 403,
response: expectNotSpaceAwareRbacForbidden,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbidden,
},
doesntExist: {
statusCode: 403,
response: expectDoesntExistRbacForbidden,
},
},
});
bulkUpdateTest(`rbac user with all at space_1`, {
user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER,
tests: {
spaceAware: {
statusCode: 403,
response: expectSpaceAwareRbacForbidden,
},
notSpaceAware: {
statusCode: 403,
response: expectNotSpaceAwareRbacForbidden,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbidden,
},
doesntExist: {
statusCode: 403,
response: expectDoesntExistRbacForbidden,
},
},
});
bulkUpdateTest(`rbac user with read at space_1`, {
user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER,
tests: {
spaceAware: {
statusCode: 403,
response: expectSpaceAwareRbacForbidden,
},
notSpaceAware: {
statusCode: 403,
response: expectNotSpaceAwareRbacForbidden,
},
hiddenType: {
statusCode: 403,
response: expectHiddenTypeRbacForbidden,
},
doesntExist: {
statusCode: 403,
response: expectDoesntExistRbacForbidden,
},
},
});
});
}

View file

@ -28,5 +28,6 @@ export default function({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./import'));
loadTestFile(require.resolve('./resolve_import_errors'));
loadTestFile(require.resolve('./update'));
loadTestFile(require.resolve('./bulk_update'));
});
}

View file

@ -0,0 +1,92 @@
/*
* 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 { FtrProviderContext } from '../../common/ftr_provider_context';
import { bulkUpdateTestSuiteFactory } from '../../common/suites/bulk_update';
export default function({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('bulkUpdate', () => {
const {
createExpectSpaceAwareNotFound,
expectSpaceAwareResults,
createExpectDoesntExistNotFound,
expectNotSpaceAwareResults,
expectSpaceNotFound,
bulkUpdateTest,
} = bulkUpdateTestSuiteFactory(esArchiver, supertest);
bulkUpdateTest(`in the default space`, {
spaceId: SPACES.DEFAULT.spaceId,
tests: {
spaceAware: {
statusCode: 200,
response: expectSpaceAwareResults,
},
notSpaceAware: {
statusCode: 200,
response: expectNotSpaceAwareResults,
},
hiddenType: {
statusCode: 200,
response: expectSpaceNotFound,
},
doesntExist: {
statusCode: 200,
response: createExpectDoesntExistNotFound(SPACES.DEFAULT.spaceId),
},
},
});
bulkUpdateTest('in the current space (space_1)', {
spaceId: SPACES.SPACE_1.spaceId,
tests: {
spaceAware: {
statusCode: 200,
response: expectSpaceAwareResults,
},
notSpaceAware: {
statusCode: 200,
response: expectNotSpaceAwareResults,
},
hiddenType: {
statusCode: 200,
response: expectSpaceNotFound,
},
doesntExist: {
statusCode: 200,
response: createExpectDoesntExistNotFound(SPACES.SPACE_1.spaceId),
},
},
});
bulkUpdateTest('objects that exist in another space (space_1)', {
spaceId: SPACES.DEFAULT.spaceId,
otherSpaceId: SPACES.SPACE_1.spaceId,
tests: {
spaceAware: {
statusCode: 200,
response: createExpectSpaceAwareNotFound(SPACES.SPACE_1.spaceId),
},
notSpaceAware: {
statusCode: 200,
response: expectNotSpaceAwareResults,
},
hiddenType: {
statusCode: 200,
response: expectSpaceNotFound,
},
doesntExist: {
statusCode: 200,
response: createExpectDoesntExistNotFound(),
},
},
});
});
}

View file

@ -20,5 +20,6 @@ export default function({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./import'));
loadTestFile(require.resolve('./resolve_import_errors'));
loadTestFile(require.resolve('./update'));
loadTestFile(require.resolve('./bulk_update'));
});
}