mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
Change saved object bulkUpdate to work across multiple namespaces (#75478)
This commit is contained in:
parent
fcead4ffde
commit
52d044c74a
34 changed files with 731 additions and 226 deletions
|
@ -28,6 +28,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
|
|||
| [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) | |
|
||||
| [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) | |
|
||||
| [SavedObjectsSerializer](./kibana-plugin-core-server.savedobjectsserializer.md) | A serializer that can be used to manually convert [raw](./kibana-plugin-core-server.savedobjectsrawdoc.md) or [sanitized](./kibana-plugin-core-server.savedobjectsanitizeddoc.md) documents to the other kind. |
|
||||
| [SavedObjectsUtils](./kibana-plugin-core-server.savedobjectsutils.md) | |
|
||||
| [SavedObjectTypeRegistry](./kibana-plugin-core-server.savedobjecttyperegistry.md) | Registry holding information about all the registered [saved object types](./kibana-plugin-core-server.savedobjectstype.md)<!-- -->. |
|
||||
|
||||
## Enumerations
|
||||
|
|
|
@ -17,5 +17,6 @@ export interface SavedObjectsBulkUpdateObject<T = unknown> extends Pick<SavedObj
|
|||
| --- | --- | --- |
|
||||
| [attributes](./kibana-plugin-core-server.savedobjectsbulkupdateobject.attributes.md) | <code>Partial<T></code> | The data for a Saved Object is stored as an object in the <code>attributes</code> property. |
|
||||
| [id](./kibana-plugin-core-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> |
|
||||
| [namespace](./kibana-plugin-core-server.savedobjectsbulkupdateobject.namespace.md) | <code>string</code> | Optional namespace string to use when searching for this object. If this is defined, it will supersede the namespace ID that is in [SavedObjectsBulkUpdateOptions](./kibana-plugin-core-server.savedobjectsbulkupdateoptions.md)<!-- -->.<!-- -->Note: the default namespace's string representation is <code>'default'</code>, and its ID representation is <code>undefined</code>. |
|
||||
| [type](./kibana-plugin-core-server.savedobjectsbulkupdateobject.type.md) | <code>string</code> | The type of this Saved Object. Each plugin can define it's own custom Saved Object types. |
|
||||
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsBulkUpdateObject](./kibana-plugin-core-server.savedobjectsbulkupdateobject.md) > [namespace](./kibana-plugin-core-server.savedobjectsbulkupdateobject.namespace.md)
|
||||
|
||||
## SavedObjectsBulkUpdateObject.namespace property
|
||||
|
||||
Optional namespace string to use when searching for this object. If this is defined, it will supersede the namespace ID that is in [SavedObjectsBulkUpdateOptions](./kibana-plugin-core-server.savedobjectsbulkupdateoptions.md)<!-- -->.
|
||||
|
||||
Note: the default namespace's string representation is `'default'`<!-- -->, and its ID representation is `undefined`<!-- -->.
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
namespace?: string;
|
||||
```
|
|
@ -0,0 +1,20 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUtils](./kibana-plugin-core-server.savedobjectsutils.md)
|
||||
|
||||
## SavedObjectsUtils class
|
||||
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
export declare class SavedObjectsUtils
|
||||
```
|
||||
|
||||
## Properties
|
||||
|
||||
| Property | Modifiers | Type | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| [namespaceIdToString](./kibana-plugin-core-server.savedobjectsutils.namespaceidtostring.md) | <code>static</code> | <code>(namespace?: string | undefined) => string</code> | Converts a given saved object namespace ID to its string representation. All namespace IDs have an identical string representation, with the exception of the <code>undefined</code> namespace ID (which has a namespace string of <code>'default'</code>). |
|
||||
| [namespaceStringToId](./kibana-plugin-core-server.savedobjectsutils.namespacestringtoid.md) | <code>static</code> | <code>(namespace: string) => string | undefined</code> | Converts a given saved object namespace string to its ID representation. All namespace strings have an identical ID representation, with the exception of the <code>'default'</code> namespace string (which has a namespace ID of <code>undefined</code>). |
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUtils](./kibana-plugin-core-server.savedobjectsutils.md) > [namespaceIdToString](./kibana-plugin-core-server.savedobjectsutils.namespaceidtostring.md)
|
||||
|
||||
## SavedObjectsUtils.namespaceIdToString property
|
||||
|
||||
Converts a given saved object namespace ID to its string representation. All namespace IDs have an identical string representation, with the exception of the `undefined` namespace ID (which has a namespace string of `'default'`<!-- -->).
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
static namespaceIdToString: (namespace?: string | undefined) => string;
|
||||
```
|
|
@ -0,0 +1,13 @@
|
|||
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
|
||||
|
||||
[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUtils](./kibana-plugin-core-server.savedobjectsutils.md) > [namespaceStringToId](./kibana-plugin-core-server.savedobjectsutils.namespacestringtoid.md)
|
||||
|
||||
## SavedObjectsUtils.namespaceStringToId property
|
||||
|
||||
Converts a given saved object namespace string to its ID representation. All namespace strings have an identical ID representation, with the exception of the `'default'` namespace string (which has a namespace ID of `undefined`<!-- -->).
|
||||
|
||||
<b>Signature:</b>
|
||||
|
||||
```typescript
|
||||
static namespaceStringToId: (namespace: string) => string | undefined;
|
||||
```
|
|
@ -293,6 +293,7 @@ export {
|
|||
SavedObjectsTypeManagementDefinition,
|
||||
SavedObjectMigrationMap,
|
||||
SavedObjectMigrationFn,
|
||||
SavedObjectsUtils,
|
||||
exportSavedObjectsToStream,
|
||||
importSavedObjectsFromStream,
|
||||
resolveSavedObjectsImportErrors,
|
||||
|
|
|
@ -40,6 +40,7 @@ export const registerBulkUpdateRoute = (router: IRouter) => {
|
|||
})
|
||||
)
|
||||
),
|
||||
namespace: schema.maybe(schema.string({ minLength: 1 })),
|
||||
})
|
||||
),
|
||||
},
|
||||
|
|
|
@ -27,6 +27,7 @@ export {
|
|||
SavedObjectsErrorHelpers,
|
||||
SavedObjectsClientFactory,
|
||||
SavedObjectsClientFactoryProvider,
|
||||
SavedObjectsUtils,
|
||||
} from './lib';
|
||||
|
||||
export * from './saved_objects_client';
|
||||
|
|
|
@ -30,3 +30,5 @@ export {
|
|||
} from './scoped_client_provider';
|
||||
|
||||
export { SavedObjectsErrorHelpers } from './errors';
|
||||
|
||||
export { SavedObjectsUtils } from './utils';
|
||||
|
|
|
@ -155,27 +155,33 @@ describe('SavedObjectsRepository', () => {
|
|||
log: {},
|
||||
});
|
||||
|
||||
const getMockGetResponse = ({ type, id, references, namespace, originId }) => ({
|
||||
// NOTE: Elasticsearch returns more fields (_index, _type) but the SavedObjectsRepository method ignores these
|
||||
found: true,
|
||||
_id: `${registry.isSingleNamespace(type) && namespace ? `${namespace}:` : ''}${type}:${id}`,
|
||||
...mockVersionProps,
|
||||
_source: {
|
||||
...(registry.isSingleNamespace(type) && { namespace }),
|
||||
...(registry.isMultiNamespace(type) && { namespaces: [namespace ?? 'default'] }),
|
||||
...(originId && { originId }),
|
||||
type,
|
||||
[type]: { title: 'Testing' },
|
||||
references,
|
||||
specialProperty: 'specialValue',
|
||||
...mockTimestampFields,
|
||||
},
|
||||
});
|
||||
const getMockGetResponse = (
|
||||
{ type, id, references, namespace: objectNamespace, originId },
|
||||
namespace
|
||||
) => {
|
||||
const namespaceId = objectNamespace === 'default' ? undefined : objectNamespace ?? namespace;
|
||||
return {
|
||||
// NOTE: Elasticsearch returns more fields (_index, _type) but the SavedObjectsRepository method ignores these
|
||||
found: true,
|
||||
_id: `${
|
||||
registry.isSingleNamespace(type) && namespaceId ? `${namespaceId}:` : ''
|
||||
}${type}:${id}`,
|
||||
...mockVersionProps,
|
||||
_source: {
|
||||
...(registry.isSingleNamespace(type) && { namespace: namespaceId }),
|
||||
...(registry.isMultiNamespace(type) && { namespaces: [namespaceId ?? 'default'] }),
|
||||
...(originId && { originId }),
|
||||
type,
|
||||
[type]: { title: 'Testing' },
|
||||
references,
|
||||
specialProperty: 'specialValue',
|
||||
...mockTimestampFields,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getMockMgetResponse = (objects, namespace) => ({
|
||||
docs: objects.map((obj) =>
|
||||
obj.found === false ? obj : getMockGetResponse({ ...obj, namespace })
|
||||
),
|
||||
docs: objects.map((obj) => (obj.found === false ? obj : getMockGetResponse(obj, namespace))),
|
||||
});
|
||||
|
||||
expect.extend({
|
||||
|
@ -586,6 +592,16 @@ describe('SavedObjectsRepository', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it(`normalizes options.namespace from 'default' to undefined`, async () => {
|
||||
await bulkCreateSuccess([obj1, obj2], { namespace: 'default' });
|
||||
const expected = expect.not.objectContaining({ namespace: 'default' });
|
||||
const body = [expect.any(Object), expected, expect.any(Object), expected];
|
||||
expect(client.bulk).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`doesn't add namespace to request body for any types that are not single-namespace`, async () => {
|
||||
const objects = [
|
||||
{ ...obj1, type: NAMESPACE_AGNOSTIC_TYPE },
|
||||
|
@ -653,19 +669,19 @@ describe('SavedObjectsRepository', () => {
|
|||
});
|
||||
|
||||
it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => {
|
||||
const getId = (type, id) => `${namespace}:${type}:${id}`;
|
||||
const getId = (type, id) => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix)
|
||||
await bulkCreateSuccess([obj1, obj2], { namespace });
|
||||
expectClientCallArgsAction([obj1, obj2], { method: 'create', getId });
|
||||
});
|
||||
|
||||
it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => {
|
||||
const getId = (type, id) => `${type}:${id}`;
|
||||
const getId = (type, id) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix)
|
||||
await bulkCreateSuccess([obj1, obj2]);
|
||||
expectClientCallArgsAction([obj1, obj2], { method: 'create', getId });
|
||||
});
|
||||
|
||||
it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => {
|
||||
const getId = (type, id) => `${type}:${id}`;
|
||||
const getId = (type, id) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix)
|
||||
const objects = [
|
||||
{ ...obj1, type: NAMESPACE_AGNOSTIC_TYPE },
|
||||
{ ...obj2, type: MULTI_NAMESPACE_TYPE },
|
||||
|
@ -972,19 +988,25 @@ describe('SavedObjectsRepository', () => {
|
|||
|
||||
describe('client calls', () => {
|
||||
it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => {
|
||||
const getId = (type, id) => `${namespace}:${type}:${id}`;
|
||||
const getId = (type, id) => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix)
|
||||
await bulkGetSuccess([obj1, obj2], { namespace });
|
||||
_expectClientCallArgs([obj1, obj2], { getId });
|
||||
});
|
||||
|
||||
it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => {
|
||||
const getId = (type, id) => `${type}:${id}`;
|
||||
const getId = (type, id) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix)
|
||||
await bulkGetSuccess([obj1, obj2]);
|
||||
_expectClientCallArgs([obj1, obj2], { getId });
|
||||
});
|
||||
|
||||
it(`normalizes options.namespace from 'default' to undefined`, async () => {
|
||||
const getId = (type, id) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix)
|
||||
await bulkGetSuccess([obj1, obj2], { namespace: 'default' });
|
||||
_expectClientCallArgs([obj1, obj2], { getId });
|
||||
});
|
||||
|
||||
it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => {
|
||||
const getId = (type, id) => `${type}:${id}`;
|
||||
const getId = (type, id) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix)
|
||||
let objects = [obj1, obj2].map((obj) => ({ ...obj, type: NAMESPACE_AGNOSTIC_TYPE }));
|
||||
await bulkGetSuccess(objects, { namespace });
|
||||
_expectClientCallArgs(objects, { getId });
|
||||
|
@ -1327,32 +1349,66 @@ describe('SavedObjectsRepository', () => {
|
|||
});
|
||||
|
||||
it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => {
|
||||
const getId = (type, id) => `${namespace}:${type}:${id}`;
|
||||
const getId = (type, id) => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix)
|
||||
await bulkUpdateSuccess([obj1, obj2], { namespace });
|
||||
expectClientCallArgsAction([obj1, obj2], { method: 'update', getId });
|
||||
|
||||
jest.clearAllMocks();
|
||||
// test again with object namespace string that supersedes the operation's namespace ID
|
||||
await bulkUpdateSuccess([
|
||||
{ ...obj1, namespace },
|
||||
{ ...obj2, namespace },
|
||||
]);
|
||||
expectClientCallArgsAction([obj1, obj2], { method: 'update', getId });
|
||||
});
|
||||
|
||||
it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => {
|
||||
const getId = (type, id) => `${type}:${id}`;
|
||||
const getId = (type, id) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix)
|
||||
await bulkUpdateSuccess([obj1, obj2]);
|
||||
expectClientCallArgsAction([obj1, obj2], { method: 'update', getId });
|
||||
|
||||
jest.clearAllMocks();
|
||||
// test again with object namespace string that supersedes the operation's namespace ID
|
||||
await bulkUpdateSuccess(
|
||||
[
|
||||
{ ...obj1, namespace: 'default' },
|
||||
{ ...obj2, namespace: 'default' },
|
||||
],
|
||||
{ namespace }
|
||||
);
|
||||
expectClientCallArgsAction([obj1, obj2], { method: 'update', getId });
|
||||
});
|
||||
|
||||
it(`normalizes options.namespace from 'default' to undefined`, async () => {
|
||||
const getId = (type, id) => `${type}:${id}`;
|
||||
await bulkUpdateSuccess([obj1, obj2], { namespace: 'default' });
|
||||
expectClientCallArgsAction([obj1, obj2], { method: 'update', getId });
|
||||
});
|
||||
|
||||
it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => {
|
||||
const getId = (type, id) => `${type}:${id}`;
|
||||
const objects1 = [{ ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }];
|
||||
await bulkUpdateSuccess(objects1, { namespace });
|
||||
expectClientCallArgsAction(objects1, { method: 'update', getId });
|
||||
client.bulk.mockClear();
|
||||
const getId = (type, id) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix)
|
||||
const overrides = {
|
||||
// bulkUpdate uses a preflight `get` request for multi-namespace saved objects, and specifies that version on `update`
|
||||
// we aren't testing for this here, but we need to include Jest assertions so this test doesn't fail
|
||||
if_primary_term: expect.any(Number),
|
||||
if_seq_no: expect.any(Number),
|
||||
};
|
||||
const objects2 = [{ ...obj2, type: MULTI_NAMESPACE_TYPE }];
|
||||
await bulkUpdateSuccess(objects2, { namespace });
|
||||
expectClientCallArgsAction(objects2, { method: 'update', getId, overrides }, 2);
|
||||
const _obj1 = { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE };
|
||||
const _obj2 = { ...obj2, type: MULTI_NAMESPACE_TYPE };
|
||||
|
||||
await bulkUpdateSuccess([_obj1], { namespace });
|
||||
expectClientCallArgsAction([_obj1], { method: 'update', getId });
|
||||
client.bulk.mockClear();
|
||||
await bulkUpdateSuccess([_obj2], { namespace });
|
||||
expectClientCallArgsAction([_obj2], { method: 'update', getId, overrides }, 2);
|
||||
|
||||
jest.clearAllMocks();
|
||||
// test again with object namespace string that supersedes the operation's namespace ID
|
||||
await bulkUpdateSuccess([{ ..._obj1, namespace }]);
|
||||
expectClientCallArgsAction([_obj1], { method: 'update', getId });
|
||||
client.bulk.mockClear();
|
||||
await bulkUpdateSuccess([{ ..._obj2, namespace }]);
|
||||
expectClientCallArgsAction([_obj2], { method: 'update', getId, overrides }, 2);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1581,19 +1637,25 @@ describe('SavedObjectsRepository', () => {
|
|||
});
|
||||
|
||||
it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => {
|
||||
const getId = (type, id) => `${namespace}:${type}:${id}`;
|
||||
const getId = (type, id) => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix)
|
||||
await checkConflictsSuccess([obj1, obj2], { namespace });
|
||||
_expectClientCallArgs([obj1, obj2], { getId });
|
||||
});
|
||||
|
||||
it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => {
|
||||
const getId = (type, id) => `${type}:${id}`;
|
||||
const getId = (type, id) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix)
|
||||
await checkConflictsSuccess([obj1, obj2]);
|
||||
_expectClientCallArgs([obj1, obj2], { getId });
|
||||
});
|
||||
|
||||
it(`normalizes options.namespace from 'default' to undefined`, async () => {
|
||||
const getId = (type, id) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix)
|
||||
await checkConflictsSuccess([obj1, obj2], { namespace: 'default' });
|
||||
_expectClientCallArgs([obj1, obj2], { getId });
|
||||
});
|
||||
|
||||
it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => {
|
||||
const getId = (type, id) => `${type}:${id}`;
|
||||
const getId = (type, id) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix)
|
||||
// obj3 is multi-namespace, and obj6 is namespace-agnostic
|
||||
await checkConflictsSuccess([obj3, obj6], { namespace });
|
||||
_expectClientCallArgs([obj3, obj6], { getId });
|
||||
|
@ -1816,6 +1878,16 @@ describe('SavedObjectsRepository', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it(`normalizes options.namespace from 'default' to undefined`, async () => {
|
||||
await createSuccess(type, attributes, { id, namespace: 'default' });
|
||||
expect(client.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: `${type}:${id}`,
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => {
|
||||
await createSuccess(NAMESPACE_AGNOSTIC_TYPE, attributes, { id, namespace });
|
||||
expect(client.create).toHaveBeenCalledWith(
|
||||
|
@ -1852,11 +1924,7 @@ describe('SavedObjectsRepository', () => {
|
|||
});
|
||||
|
||||
it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => {
|
||||
const response = getMockGetResponse({
|
||||
type: MULTI_NAMESPACE_TYPE,
|
||||
id,
|
||||
namespace: 'bar-namespace',
|
||||
});
|
||||
const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, 'bar-namespace');
|
||||
client.get.mockResolvedValueOnce(
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
|
||||
);
|
||||
|
@ -1959,7 +2027,7 @@ describe('SavedObjectsRepository', () => {
|
|||
|
||||
const deleteSuccess = async (type, id, options) => {
|
||||
if (registry.isMultiNamespace(type)) {
|
||||
const mockGetResponse = getMockGetResponse({ type, id, namespace: options?.namespace });
|
||||
const mockGetResponse = getMockGetResponse({ type, id }, options?.namespace);
|
||||
client.get.mockResolvedValueOnce(
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise(mockGetResponse)
|
||||
);
|
||||
|
@ -2035,6 +2103,14 @@ describe('SavedObjectsRepository', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it(`normalizes options.namespace from 'default' to undefined`, async () => {
|
||||
await deleteSuccess(type, id, { namespace: 'default' });
|
||||
expect(client.delete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: `${type}:${id}` }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => {
|
||||
await deleteSuccess(NAMESPACE_AGNOSTIC_TYPE, id, { namespace });
|
||||
expect(client.delete).toHaveBeenCalledWith(
|
||||
|
@ -2085,7 +2161,7 @@ describe('SavedObjectsRepository', () => {
|
|||
});
|
||||
|
||||
it(`throws when the type is multi-namespace and the document exists, but not in this namespace`, async () => {
|
||||
const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace });
|
||||
const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace);
|
||||
client.get.mockResolvedValueOnce(
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
|
||||
);
|
||||
|
@ -2660,14 +2736,16 @@ describe('SavedObjectsRepository', () => {
|
|||
const originId = 'some-origin-id';
|
||||
|
||||
const getSuccess = async (type, id, options, includeOriginId) => {
|
||||
const response = getMockGetResponse({
|
||||
type,
|
||||
id,
|
||||
namespace: options?.namespace,
|
||||
// "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the
|
||||
// operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response.
|
||||
...(includeOriginId && { originId }),
|
||||
});
|
||||
const response = getMockGetResponse(
|
||||
{
|
||||
type,
|
||||
id,
|
||||
// "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the
|
||||
// operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response.
|
||||
...(includeOriginId && { originId }),
|
||||
},
|
||||
options?.namespace
|
||||
);
|
||||
client.get.mockResolvedValueOnce(
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
|
||||
);
|
||||
|
@ -2702,6 +2780,16 @@ describe('SavedObjectsRepository', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it(`normalizes options.namespace from 'default' to undefined`, async () => {
|
||||
await getSuccess(type, id, { namespace: 'default' });
|
||||
expect(client.get).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: `${type}:${id}`,
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => {
|
||||
await getSuccess(NAMESPACE_AGNOSTIC_TYPE, id, { namespace });
|
||||
expect(client.get).toHaveBeenCalledWith(
|
||||
|
@ -2756,7 +2844,7 @@ describe('SavedObjectsRepository', () => {
|
|||
});
|
||||
|
||||
it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => {
|
||||
const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace });
|
||||
const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace);
|
||||
client.get.mockResolvedValueOnce(
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
|
||||
);
|
||||
|
@ -2812,7 +2900,7 @@ describe('SavedObjectsRepository', () => {
|
|||
const incrementCounterSuccess = async (type, id, field, options) => {
|
||||
const isMultiNamespace = registry.isMultiNamespace(type);
|
||||
if (isMultiNamespace) {
|
||||
const response = getMockGetResponse({ type, id, namespace: options?.namespace });
|
||||
const response = getMockGetResponse({ type, id }, options?.namespace);
|
||||
client.get.mockResolvedValueOnce(
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
|
||||
);
|
||||
|
@ -2883,6 +2971,16 @@ describe('SavedObjectsRepository', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it(`normalizes options.namespace from 'default' to undefined`, async () => {
|
||||
await incrementCounterSuccess(type, id, field, { namespace: 'default' });
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: `${type}:${id}`,
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => {
|
||||
await incrementCounterSuccess(NAMESPACE_AGNOSTIC_TYPE, id, field, { namespace });
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
|
@ -2949,11 +3047,7 @@ describe('SavedObjectsRepository', () => {
|
|||
});
|
||||
|
||||
it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => {
|
||||
const response = getMockGetResponse({
|
||||
type: MULTI_NAMESPACE_TYPE,
|
||||
id,
|
||||
namespace: 'bar-namespace',
|
||||
});
|
||||
const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, 'bar-namespace');
|
||||
client.get.mockResolvedValueOnce(
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
|
||||
);
|
||||
|
@ -3246,7 +3340,7 @@ describe('SavedObjectsRepository', () => {
|
|||
expect(client.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`throws when type is not namespace-agnostic`, async () => {
|
||||
it(`throws when type is not multi-namespace`, async () => {
|
||||
const test = async (type) => {
|
||||
const message = `${type} doesn't support multiple namespaces`;
|
||||
await expectBadRequestError(type, id, [namespace1, namespace2], message);
|
||||
|
@ -3388,7 +3482,7 @@ describe('SavedObjectsRepository', () => {
|
|||
|
||||
const updateSuccess = async (type, id, attributes, options, includeOriginId) => {
|
||||
if (registry.isMultiNamespace(type)) {
|
||||
const mockGetResponse = getMockGetResponse({ type, id, namespace: options?.namespace });
|
||||
const mockGetResponse = getMockGetResponse({ type, id }, options?.namespace);
|
||||
client.get.mockResolvedValueOnce(
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise(mockGetResponse)
|
||||
);
|
||||
|
@ -3519,6 +3613,14 @@ describe('SavedObjectsRepository', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it(`normalizes options.namespace from 'default' to undefined`, async () => {
|
||||
await updateSuccess(type, id, attributes, { references, namespace: 'default' });
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: expect.stringMatching(`${type}:${id}`) }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => {
|
||||
await updateSuccess(NAMESPACE_AGNOSTIC_TYPE, id, attributes, { namespace });
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
|
@ -3589,7 +3691,7 @@ describe('SavedObjectsRepository', () => {
|
|||
});
|
||||
|
||||
it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => {
|
||||
const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace });
|
||||
const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }, namespace);
|
||||
client.get.mockResolvedValueOnce(
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
|
||||
);
|
||||
|
|
|
@ -67,6 +67,7 @@ import {
|
|||
} from '../../types';
|
||||
import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry';
|
||||
import { validateConvertFilterToKueryNode } from './filter_utils';
|
||||
import { SavedObjectsUtils } from './utils';
|
||||
|
||||
// BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository
|
||||
// so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient.
|
||||
|
@ -220,13 +221,13 @@ export class SavedObjectsRepository {
|
|||
const {
|
||||
id,
|
||||
migrationVersion,
|
||||
namespace,
|
||||
overwrite = false,
|
||||
references = [],
|
||||
refresh = DEFAULT_REFRESH_SETTING,
|
||||
originId,
|
||||
version,
|
||||
} = options;
|
||||
const namespace = normalizeNamespace(options.namespace);
|
||||
|
||||
if (!this._allowedTypes.includes(type)) {
|
||||
throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type);
|
||||
|
@ -293,7 +294,8 @@ export class SavedObjectsRepository {
|
|||
objects: Array<SavedObjectsBulkCreateObject<T>>,
|
||||
options: SavedObjectsCreateOptions = {}
|
||||
): Promise<SavedObjectsBulkResponse<T>> {
|
||||
const { namespace, overwrite = false, refresh = DEFAULT_REFRESH_SETTING } = options;
|
||||
const { overwrite = false, refresh = DEFAULT_REFRESH_SETTING } = options;
|
||||
const namespace = normalizeNamespace(options.namespace);
|
||||
const time = this._getCurrentTime();
|
||||
|
||||
let bulkGetRequestIndexCounter = 0;
|
||||
|
@ -468,7 +470,7 @@ export class SavedObjectsRepository {
|
|||
return { errors: [] };
|
||||
}
|
||||
|
||||
const { namespace } = options;
|
||||
const namespace = normalizeNamespace(options.namespace);
|
||||
|
||||
let bulkGetRequestIndexCounter = 0;
|
||||
const expectedBulkGetResults: Either[] = objects.map((object) => {
|
||||
|
@ -551,7 +553,8 @@ export class SavedObjectsRepository {
|
|||
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
|
||||
}
|
||||
|
||||
const { namespace, refresh = DEFAULT_REFRESH_SETTING } = options;
|
||||
const { refresh = DEFAULT_REFRESH_SETTING } = options;
|
||||
const namespace = normalizeNamespace(options.namespace);
|
||||
|
||||
const rawId = this._serializer.generateRawId(namespace, type, id);
|
||||
let preflightResult: SavedObjectsRawDoc | undefined;
|
||||
|
@ -560,7 +563,7 @@ export class SavedObjectsRepository {
|
|||
preflightResult = await this.preflightCheckIncludesNamespace(type, id, namespace);
|
||||
const existingNamespaces = getSavedObjectNamespaces(undefined, preflightResult);
|
||||
const remainingNamespaces = existingNamespaces?.filter(
|
||||
(x) => x !== getNamespaceString(namespace)
|
||||
(x) => x !== SavedObjectsUtils.namespaceIdToString(namespace)
|
||||
);
|
||||
|
||||
if (remainingNamespaces?.length) {
|
||||
|
@ -658,7 +661,7 @@ export class SavedObjectsRepository {
|
|||
}
|
||||
`,
|
||||
lang: 'painless',
|
||||
params: { namespace: getNamespaceString(namespace) },
|
||||
params: { namespace },
|
||||
},
|
||||
conflicts: 'proceed',
|
||||
...getSearchDsl(this._mappings, this._registry, {
|
||||
|
@ -814,7 +817,7 @@ export class SavedObjectsRepository {
|
|||
objects: SavedObjectsBulkGetObject[] = [],
|
||||
options: SavedObjectsBaseOptions = {}
|
||||
): Promise<SavedObjectsBulkResponse<T>> {
|
||||
const { namespace } = options;
|
||||
const namespace = normalizeNamespace(options.namespace);
|
||||
|
||||
if (objects.length === 0) {
|
||||
return { saved_objects: [] };
|
||||
|
@ -884,7 +887,9 @@ export class SavedObjectsRepository {
|
|||
const { originId, updated_at: updatedAt } = doc._source;
|
||||
let namespaces = [];
|
||||
if (!this._registry.isNamespaceAgnostic(type)) {
|
||||
namespaces = doc._source.namespaces ?? [getNamespaceString(doc._source.namespace)];
|
||||
namespaces = doc._source.namespaces ?? [
|
||||
SavedObjectsUtils.namespaceIdToString(doc._source.namespace),
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -920,7 +925,7 @@ export class SavedObjectsRepository {
|
|||
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
|
||||
}
|
||||
|
||||
const { namespace } = options;
|
||||
const namespace = normalizeNamespace(options.namespace);
|
||||
|
||||
const { body, statusCode } = await this.client.get<GetResponse<SavedObjectsRawDocSource>>(
|
||||
{
|
||||
|
@ -941,7 +946,9 @@ export class SavedObjectsRepository {
|
|||
|
||||
let namespaces: string[] = [];
|
||||
if (!this._registry.isNamespaceAgnostic(type)) {
|
||||
namespaces = body._source.namespaces ?? [getNamespaceString(body._source.namespace)];
|
||||
namespaces = body._source.namespaces ?? [
|
||||
SavedObjectsUtils.namespaceIdToString(body._source.namespace),
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -978,7 +985,8 @@ export class SavedObjectsRepository {
|
|||
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
|
||||
}
|
||||
|
||||
const { version, namespace, references, refresh = DEFAULT_REFRESH_SETTING } = options;
|
||||
const { version, references, refresh = DEFAULT_REFRESH_SETTING } = options;
|
||||
const namespace = normalizeNamespace(options.namespace);
|
||||
|
||||
let preflightResult: SavedObjectsRawDoc | undefined;
|
||||
if (this._registry.isMultiNamespace(type)) {
|
||||
|
@ -1016,7 +1024,9 @@ export class SavedObjectsRepository {
|
|||
const { originId } = body.get._source;
|
||||
let namespaces = [];
|
||||
if (!this._registry.isNamespaceAgnostic(type)) {
|
||||
namespaces = body.get._source.namespaces ?? [getNamespaceString(body.get._source.namespace)];
|
||||
namespaces = body.get._source.namespaces ?? [
|
||||
SavedObjectsUtils.namespaceIdToString(body.get._source.namespace),
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -1060,6 +1070,7 @@ export class SavedObjectsRepository {
|
|||
}
|
||||
|
||||
const { version, namespace, refresh = DEFAULT_REFRESH_SETTING } = options;
|
||||
// we do not need to normalize the namespace to its ID format, since it will be converted to a namespace string before being used
|
||||
|
||||
const rawId = this._serializer.generateRawId(undefined, type, id);
|
||||
const preflightResult = await this.preflightCheckIncludesNamespace(type, id, namespace);
|
||||
|
@ -1122,6 +1133,7 @@ export class SavedObjectsRepository {
|
|||
}
|
||||
|
||||
const { namespace, refresh = DEFAULT_REFRESH_SETTING } = options;
|
||||
// we do not need to normalize the namespace to its ID format, since it will be converted to a namespace string before being used
|
||||
|
||||
const rawId = this._serializer.generateRawId(undefined, type, id);
|
||||
const preflightResult = await this.preflightCheckIncludesNamespace(type, id, namespace);
|
||||
|
@ -1208,7 +1220,7 @@ export class SavedObjectsRepository {
|
|||
options: SavedObjectsBulkUpdateOptions = {}
|
||||
): Promise<SavedObjectsBulkUpdateResponse<T>> {
|
||||
const time = this._getCurrentTime();
|
||||
const { namespace } = options;
|
||||
const namespace = normalizeNamespace(options.namespace);
|
||||
|
||||
let bulkGetRequestIndexCounter = 0;
|
||||
const expectedBulkGetResults: Either[] = objects.map((object) => {
|
||||
|
@ -1225,7 +1237,9 @@ export class SavedObjectsRepository {
|
|||
};
|
||||
}
|
||||
|
||||
const { attributes, references, version } = object;
|
||||
const { attributes, references, version, namespace: objectNamespace } = object;
|
||||
// `objectNamespace` is a namespace string, while `namespace` is a namespace ID.
|
||||
// The object namespace string, if defined, will supersede the operation's namespace ID.
|
||||
|
||||
const documentToSave = {
|
||||
[type]: attributes,
|
||||
|
@ -1242,16 +1256,24 @@ export class SavedObjectsRepository {
|
|||
id,
|
||||
version,
|
||||
documentToSave,
|
||||
objectNamespace,
|
||||
...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const getNamespaceId = (objectNamespace?: string) =>
|
||||
objectNamespace !== undefined
|
||||
? SavedObjectsUtils.namespaceStringToId(objectNamespace)
|
||||
: namespace;
|
||||
const getNamespaceString = (objectNamespace?: string) =>
|
||||
objectNamespace ?? SavedObjectsUtils.namespaceIdToString(namespace);
|
||||
|
||||
const bulkGetDocs = expectedBulkGetResults
|
||||
.filter(isRight)
|
||||
.filter(({ value }) => value.esRequestIndex !== undefined)
|
||||
.map(({ value: { type, id } }) => ({
|
||||
_id: this._serializer.generateRawId(namespace, type, id),
|
||||
.map(({ value: { type, id, objectNamespace } }) => ({
|
||||
_id: this._serializer.generateRawId(getNamespaceId(objectNamespace), type, id),
|
||||
_index: this.getIndexForType(type),
|
||||
_source: ['type', 'namespaces'],
|
||||
}));
|
||||
|
@ -1276,14 +1298,25 @@ export class SavedObjectsRepository {
|
|||
return expectedBulkGetResult;
|
||||
}
|
||||
|
||||
const { esRequestIndex, id, type, version, documentToSave } = expectedBulkGetResult.value;
|
||||
const {
|
||||
esRequestIndex,
|
||||
id,
|
||||
type,
|
||||
version,
|
||||
documentToSave,
|
||||
objectNamespace,
|
||||
} = expectedBulkGetResult.value;
|
||||
|
||||
let namespaces;
|
||||
let versionProperties;
|
||||
if (esRequestIndex !== undefined) {
|
||||
const indexFound = bulkGetResponse?.statusCode !== 404;
|
||||
const actualResult = indexFound ? bulkGetResponse?.body.docs[esRequestIndex] : undefined;
|
||||
const docFound = indexFound && actualResult.found === true;
|
||||
if (!docFound || !this.rawDocExistsInNamespace(actualResult, namespace)) {
|
||||
if (
|
||||
!docFound ||
|
||||
!this.rawDocExistsInNamespace(actualResult, getNamespaceId(objectNamespace))
|
||||
) {
|
||||
return {
|
||||
tag: 'Left' as 'Left',
|
||||
error: {
|
||||
|
@ -1294,12 +1327,13 @@ export class SavedObjectsRepository {
|
|||
};
|
||||
}
|
||||
namespaces = actualResult._source.namespaces ?? [
|
||||
getNamespaceString(actualResult._source.namespace),
|
||||
SavedObjectsUtils.namespaceIdToString(actualResult._source.namespace),
|
||||
];
|
||||
versionProperties = getExpectedVersionProperties(version, actualResult);
|
||||
} else {
|
||||
if (this._registry.isSingleNamespace(type)) {
|
||||
namespaces = [getNamespaceString(namespace)];
|
||||
// if `objectNamespace` is undefined, fall back to `options.namespace`
|
||||
namespaces = [getNamespaceString(objectNamespace)];
|
||||
}
|
||||
versionProperties = getExpectedVersionProperties(version);
|
||||
}
|
||||
|
@ -1315,7 +1349,7 @@ export class SavedObjectsRepository {
|
|||
bulkUpdateParams.push(
|
||||
{
|
||||
update: {
|
||||
_id: this._serializer.generateRawId(namespace, type, id),
|
||||
_id: this._serializer.generateRawId(getNamespaceId(objectNamespace), type, id),
|
||||
_index: this.getIndexForType(type),
|
||||
...versionProperties,
|
||||
},
|
||||
|
@ -1401,7 +1435,8 @@ export class SavedObjectsRepository {
|
|||
throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type);
|
||||
}
|
||||
|
||||
const { migrationVersion, namespace, refresh = DEFAULT_REFRESH_SETTING } = options;
|
||||
const { migrationVersion, refresh = DEFAULT_REFRESH_SETTING } = options;
|
||||
const namespace = normalizeNamespace(options.namespace);
|
||||
|
||||
const time = this._getCurrentTime();
|
||||
let savedObjectNamespace;
|
||||
|
@ -1495,7 +1530,7 @@ export class SavedObjectsRepository {
|
|||
const savedObject = this._serializer.rawToSavedObject(raw);
|
||||
const { namespace, type } = savedObject;
|
||||
if (this._registry.isSingleNamespace(type)) {
|
||||
savedObject.namespaces = [getNamespaceString(namespace)];
|
||||
savedObject.namespaces = [SavedObjectsUtils.namespaceIdToString(namespace)];
|
||||
}
|
||||
return omit(savedObject, 'namespace') as SavedObject<T>;
|
||||
}
|
||||
|
@ -1518,7 +1553,7 @@ export class SavedObjectsRepository {
|
|||
}
|
||||
|
||||
const namespaces = raw._source.namespaces;
|
||||
return namespaces?.includes(getNamespaceString(namespace)) ?? false;
|
||||
return namespaces?.includes(SavedObjectsUtils.namespaceIdToString(namespace)) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1623,14 +1658,6 @@ function getExpectedVersionProperties(version?: string, document?: SavedObjectsR
|
|||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the string representation of a namespace.
|
||||
* The default namespace is undefined, and is represented by the string 'default'.
|
||||
*/
|
||||
function getNamespaceString(namespace?: string) {
|
||||
return namespace ?? 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string array of namespaces for a given saved object. If the saved object is undefined, the result is an array that contains the
|
||||
* current namespace. Value may be undefined if an existing saved object has no namespaces attribute; this should not happen in normal
|
||||
|
@ -1646,9 +1673,16 @@ function getSavedObjectNamespaces(
|
|||
if (document) {
|
||||
return document._source?.namespaces;
|
||||
}
|
||||
return [getNamespaceString(namespace)];
|
||||
return [SavedObjectsUtils.namespaceIdToString(namespace)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that a namespace is always in its namespace ID representation.
|
||||
* This allows `'default'` to be used interchangeably with `undefined`.
|
||||
*/
|
||||
const normalizeNamespace = (namespace?: string) =>
|
||||
namespace === undefined ? namespace : SavedObjectsUtils.namespaceStringToId(namespace);
|
||||
|
||||
/**
|
||||
* Extracts the contents of a decorated error to return the attributes for bulk operations.
|
||||
*/
|
||||
|
|
|
@ -21,6 +21,7 @@ import { esKuery, KueryNode } from '../../../../../../plugins/data/server';
|
|||
|
||||
import { getRootPropertiesObjects, IndexMapping } from '../../../mappings';
|
||||
import { ISavedObjectTypeRegistry } from '../../../saved_objects_type_registry';
|
||||
import { DEFAULT_NAMESPACE_STRING } from '../utils';
|
||||
|
||||
/**
|
||||
* Gets the types based on the type. Uses mappings to support
|
||||
|
@ -73,7 +74,7 @@ function getFieldsForTypes(
|
|||
*/
|
||||
function getClauseForType(
|
||||
registry: ISavedObjectTypeRegistry,
|
||||
namespaces: string[] = ['default'],
|
||||
namespaces: string[] = [DEFAULT_NAMESPACE_STRING],
|
||||
type: string
|
||||
) {
|
||||
if (namespaces.length === 0) {
|
||||
|
@ -88,11 +89,11 @@ function getClauseForType(
|
|||
};
|
||||
} else if (registry.isSingleNamespace(type)) {
|
||||
const should: Array<Record<string, any>> = [];
|
||||
const eligibleNamespaces = namespaces.filter((namespace) => namespace !== 'default');
|
||||
const eligibleNamespaces = namespaces.filter((x) => x !== DEFAULT_NAMESPACE_STRING);
|
||||
if (eligibleNamespaces.length > 0) {
|
||||
should.push({ terms: { namespace: eligibleNamespaces } });
|
||||
}
|
||||
if (namespaces.includes('default')) {
|
||||
if (namespaces.includes(DEFAULT_NAMESPACE_STRING)) {
|
||||
should.push({ bool: { must_not: [{ exists: { field: 'namespace' } }] } });
|
||||
}
|
||||
if (should.length === 0) {
|
||||
|
@ -162,9 +163,7 @@ export function getQueryParams({
|
|||
// would result in no results being returned, as the wildcard is treated as a literal, and not _actually_ as a wildcard.
|
||||
// We had a good discussion around the tradeoffs here: https://github.com/elastic/kibana/pull/67644#discussion_r441055716
|
||||
const normalizedNamespaces = namespaces
|
||||
? Array.from(
|
||||
new Set(namespaces.map((namespace) => (namespace === '*' ? 'default' : namespace)))
|
||||
)
|
||||
? Array.from(new Set(namespaces.map((x) => (x === '*' ? DEFAULT_NAMESPACE_STRING : x))))
|
||||
: undefined;
|
||||
|
||||
const bool: any = {
|
||||
|
|
57
src/core/server/saved_objects/service/lib/utils.test.ts
Normal file
57
src/core/server/saved_objects/service/lib/utils.test.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* 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 { SavedObjectsUtils } from './utils';
|
||||
|
||||
describe('SavedObjectsUtils', () => {
|
||||
const { namespaceIdToString, namespaceStringToId } = SavedObjectsUtils;
|
||||
|
||||
describe('#namespaceIdToString', () => {
|
||||
it('converts `undefined` to default namespace string', () => {
|
||||
expect(namespaceIdToString(undefined)).toEqual('default');
|
||||
});
|
||||
|
||||
it('leaves other namespace IDs as-is', () => {
|
||||
expect(namespaceIdToString('foo')).toEqual('foo');
|
||||
});
|
||||
|
||||
it('throws an error when a namespace ID is an empty string', () => {
|
||||
expect(() => namespaceIdToString('')).toThrowError('namespace cannot be an empty string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#namespaceStringToId', () => {
|
||||
it('converts default namespace string to `undefined`', () => {
|
||||
expect(namespaceStringToId('default')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves other namespace strings as-is', () => {
|
||||
expect(namespaceStringToId('foo')).toEqual('foo');
|
||||
});
|
||||
|
||||
it('throws an error when a namespace string is falsy', () => {
|
||||
const test = (arg: any) =>
|
||||
expect(() => namespaceStringToId(arg)).toThrowError('namespace must be a non-empty string');
|
||||
|
||||
test(undefined);
|
||||
test(null);
|
||||
test('');
|
||||
});
|
||||
});
|
||||
});
|
53
src/core/server/saved_objects/service/lib/utils.ts
Normal file
53
src/core/server/saved_objects/service/lib/utils.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* 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 const DEFAULT_NAMESPACE_STRING = 'default';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export class SavedObjectsUtils {
|
||||
/**
|
||||
* Converts a given saved object namespace ID to its string representation. All namespace IDs have an identical string representation, with
|
||||
* the exception of the `undefined` namespace ID (which has a namespace string of `'default'`).
|
||||
*
|
||||
* @param namespace The namespace ID, which must be either a non-empty string or `undefined`.
|
||||
*/
|
||||
public static namespaceIdToString = (namespace?: string) => {
|
||||
if (namespace === '') {
|
||||
throw new TypeError('namespace cannot be an empty string');
|
||||
}
|
||||
|
||||
return namespace ?? DEFAULT_NAMESPACE_STRING;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a given saved object namespace string to its ID representation. All namespace strings have an identical ID representation, with
|
||||
* the exception of the `'default'` namespace string (which has a namespace ID of `undefined`).
|
||||
*
|
||||
* @param namespace The namespace string, which must be non-empty.
|
||||
*/
|
||||
public static namespaceStringToId = (namespace: string) => {
|
||||
if (!namespace) {
|
||||
throw new TypeError('namespace must be a non-empty string');
|
||||
}
|
||||
|
||||
return namespace !== DEFAULT_NAMESPACE_STRING ? namespace : undefined;
|
||||
};
|
||||
}
|
|
@ -80,6 +80,13 @@ export interface SavedObjectsBulkUpdateObject<T = unknown>
|
|||
type: string;
|
||||
/** {@inheritdoc SavedObjectAttributes} */
|
||||
attributes: Partial<T>;
|
||||
/**
|
||||
* Optional namespace string to use when searching for this object. If this is defined, it will supersede the namespace ID that is in
|
||||
* {@link SavedObjectsBulkUpdateOptions}.
|
||||
*
|
||||
* Note: the default namespace's string representation is `'default'`, and its ID representation is `undefined`.
|
||||
**/
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -2047,6 +2047,7 @@ export interface SavedObjectsBulkResponse<T = unknown> {
|
|||
export interface SavedObjectsBulkUpdateObject<T = unknown> extends Pick<SavedObjectsUpdateOptions, 'version' | 'references'> {
|
||||
attributes: Partial<T>;
|
||||
id: string;
|
||||
namespace?: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
|
@ -2630,6 +2631,12 @@ export interface SavedObjectsUpdateResponse<T = unknown> extends Omit<SavedObjec
|
|||
references: SavedObjectReference[] | undefined;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export class SavedObjectsUtils {
|
||||
static namespaceIdToString: (namespace?: string | undefined) => string;
|
||||
static namespaceStringToId: (namespace: string) => string | undefined;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class SavedObjectTypeRegistry {
|
||||
getAllTypes(): SavedObjectsType[];
|
||||
|
|
|
@ -555,7 +555,18 @@ describe('#bulkUpdate', () => {
|
|||
});
|
||||
|
||||
describe('namespace', () => {
|
||||
const doTest = async (namespace: string, expectNamespaceInDescriptor: boolean) => {
|
||||
interface TestParams {
|
||||
optionsNamespace: string | undefined;
|
||||
objectNamespace: string | undefined;
|
||||
expectOptionsNamespaceInDescriptor: boolean;
|
||||
expectObjectNamespaceInDescriptor: boolean;
|
||||
}
|
||||
const doTest = async ({
|
||||
optionsNamespace,
|
||||
objectNamespace,
|
||||
expectOptionsNamespaceInDescriptor,
|
||||
expectObjectNamespaceInDescriptor,
|
||||
}: TestParams) => {
|
||||
const docs = [
|
||||
{
|
||||
id: 'some-id',
|
||||
|
@ -566,12 +577,13 @@ describe('#bulkUpdate', () => {
|
|||
attrThree: 'three',
|
||||
},
|
||||
version: 'some-version',
|
||||
namespace: objectNamespace,
|
||||
},
|
||||
];
|
||||
const options = { namespace };
|
||||
const options = { namespace: optionsNamespace };
|
||||
|
||||
mockBaseClient.bulkUpdate.mockResolvedValue({
|
||||
saved_objects: docs.map((doc) => ({ ...doc, references: undefined })),
|
||||
saved_objects: docs.map(({ namespace, ...doc }) => ({ ...doc, references: undefined })),
|
||||
});
|
||||
|
||||
await expect(wrapper.bulkUpdate(docs, options)).resolves.toEqual({
|
||||
|
@ -594,7 +606,11 @@ describe('#bulkUpdate', () => {
|
|||
{
|
||||
type: 'known-type',
|
||||
id: 'some-id',
|
||||
namespace: expectNamespaceInDescriptor ? namespace : undefined,
|
||||
namespace: expectObjectNamespaceInDescriptor
|
||||
? objectNamespace
|
||||
: expectOptionsNamespaceInDescriptor
|
||||
? optionsNamespace
|
||||
: undefined,
|
||||
},
|
||||
{ attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
|
||||
{ user: mockAuthenticatedUser() }
|
||||
|
@ -612,7 +628,7 @@ describe('#bulkUpdate', () => {
|
|||
attrThree: 'three',
|
||||
},
|
||||
version: 'some-version',
|
||||
|
||||
namespace: objectNamespace,
|
||||
references: undefined,
|
||||
},
|
||||
],
|
||||
|
@ -620,13 +636,46 @@ describe('#bulkUpdate', () => {
|
|||
);
|
||||
};
|
||||
|
||||
it('uses `namespace` to encrypt attributes if it is specified when type is single-namespace', async () => {
|
||||
await doTest('some-namespace', true);
|
||||
it('does not use options `namespace` or object `namespace` to encrypt attributes if neither are specified', async () => {
|
||||
await doTest({
|
||||
optionsNamespace: undefined,
|
||||
objectNamespace: undefined,
|
||||
expectOptionsNamespaceInDescriptor: false,
|
||||
expectObjectNamespaceInDescriptor: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not use `namespace` to encrypt attributes if it is specified when type is not single-namespace', async () => {
|
||||
mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false);
|
||||
await doTest('some-namespace', false);
|
||||
describe('with a single-namespace type', () => {
|
||||
it('uses options `namespace` to encrypt attributes if it is specified and object `namespace` is not', async () => {
|
||||
await doTest({
|
||||
optionsNamespace: 'some-namespace',
|
||||
objectNamespace: undefined,
|
||||
expectOptionsNamespaceInDescriptor: true,
|
||||
expectObjectNamespaceInDescriptor: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses object `namespace` to encrypt attributes if it is specified', async () => {
|
||||
// object namespace supersedes options namespace
|
||||
await doTest({
|
||||
optionsNamespace: 'some-namespace',
|
||||
objectNamespace: 'another-namespace',
|
||||
expectOptionsNamespaceInDescriptor: false,
|
||||
expectObjectNamespaceInDescriptor: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a non-single-namespace type', () => {
|
||||
it('does not use object `namespace` or options `namespace` to encrypt attributes if it is specified', async () => {
|
||||
mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false);
|
||||
await doTest({
|
||||
optionsNamespace: 'some-namespace',
|
||||
objectNamespace: 'another-namespace',
|
||||
expectOptionsNamespaceInDescriptor: false,
|
||||
expectObjectNamespaceInDescriptor: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -150,14 +150,14 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
|
|||
// sequential processing.
|
||||
const encryptedObjects = await Promise.all(
|
||||
objects.map(async (object) => {
|
||||
const { type, id, attributes } = object;
|
||||
const { type, id, attributes, namespace: objectNamespace } = object;
|
||||
if (!this.options.service.isRegistered(type)) {
|
||||
return object;
|
||||
}
|
||||
const namespace = getDescriptorNamespace(
|
||||
this.options.baseTypeRegistry,
|
||||
type,
|
||||
options?.namespace
|
||||
objectNamespace ?? options?.namespace
|
||||
);
|
||||
return {
|
||||
...object,
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { ISavedObjectTypeRegistry } from 'kibana/server';
|
||||
import { ISavedObjectTypeRegistry, SavedObjectsUtils } from '../../../../../src/core/server';
|
||||
|
||||
export const getDescriptorNamespace = (
|
||||
typeRegistry: ISavedObjectTypeRegistry,
|
||||
|
@ -12,5 +12,12 @@ export const getDescriptorNamespace = (
|
|||
namespace?: string
|
||||
) => {
|
||||
const descriptorNamespace = typeRegistry.isSingleNamespace(type) ? namespace : undefined;
|
||||
return descriptorNamespace === 'default' ? undefined : descriptorNamespace;
|
||||
return normalizeNamespace(descriptorNamespace);
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure that a namespace is always in its namespace ID representation.
|
||||
* This allows `'default'` to be used interchangeably with `undefined`.
|
||||
*/
|
||||
const normalizeNamespace = (namespace?: string) =>
|
||||
namespace === undefined ? namespace : SavedObjectsUtils.namespaceStringToId(namespace);
|
||||
|
|
|
@ -31,7 +31,9 @@ beforeEach(() => {
|
|||
|
||||
mockSpacesService = {
|
||||
getSpaceId: jest.fn(),
|
||||
namespaceToSpaceId: jest.fn().mockImplementation((namespace: string) => `${namespace}-id`),
|
||||
namespaceToSpaceId: jest
|
||||
.fn()
|
||||
.mockImplementation((namespace: string = 'default') => `${namespace}-id`),
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -41,8 +43,6 @@ describe('#checkSavedObjectsPrivileges', () => {
|
|||
const namespace2 = 'qux';
|
||||
|
||||
describe('when checking multiple namespaces', () => {
|
||||
const namespaces = [namespace1, namespace2];
|
||||
|
||||
test(`throws an error when using an empty namespaces array`, async () => {
|
||||
const checkSavedObjectsPrivileges = createFactory();
|
||||
|
||||
|
@ -58,6 +58,7 @@ describe('#checkSavedObjectsPrivileges', () => {
|
|||
mockCheckPrivileges.atSpaces.mockReturnValue(expectedResult as any);
|
||||
const checkSavedObjectsPrivileges = createFactory();
|
||||
|
||||
const namespaces = [namespace1, namespace2];
|
||||
const result = await checkSavedObjectsPrivileges(actions, namespaces);
|
||||
|
||||
expect(result).toBe(expectedResult);
|
||||
|
@ -70,6 +71,30 @@ describe('#checkSavedObjectsPrivileges', () => {
|
|||
const spaceIds = mockSpacesService!.namespaceToSpaceId.mock.results.map((x) => x.value);
|
||||
expect(mockCheckPrivileges.atSpaces).toHaveBeenCalledWith(spaceIds, actions);
|
||||
});
|
||||
|
||||
test(`de-duplicates namespaces`, async () => {
|
||||
const expectedResult = Symbol();
|
||||
mockCheckPrivileges.atSpaces.mockReturnValue(expectedResult as any);
|
||||
const checkSavedObjectsPrivileges = createFactory();
|
||||
|
||||
const namespaces = [undefined, 'default', namespace1, namespace1];
|
||||
const result = await checkSavedObjectsPrivileges(actions, namespaces);
|
||||
|
||||
expect(result).toBe(expectedResult);
|
||||
expect(mockSpacesService!.namespaceToSpaceId).toHaveBeenCalledTimes(4);
|
||||
expect(mockSpacesService!.namespaceToSpaceId).toHaveBeenNthCalledWith(1, undefined);
|
||||
expect(mockSpacesService!.namespaceToSpaceId).toHaveBeenNthCalledWith(2, 'default');
|
||||
expect(mockSpacesService!.namespaceToSpaceId).toHaveBeenNthCalledWith(3, namespace1);
|
||||
expect(mockSpacesService!.namespaceToSpaceId).toHaveBeenNthCalledWith(4, namespace1);
|
||||
expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request);
|
||||
expect(mockCheckPrivileges.atSpaces).toHaveBeenCalledTimes(1);
|
||||
const spaceIds = [
|
||||
mockSpacesService!.namespaceToSpaceId(undefined), // deduplicated with 'default'
|
||||
mockSpacesService!.namespaceToSpaceId(namespace1), // deduplicated with namespace1
|
||||
];
|
||||
expect(mockCheckPrivileges.atSpaces).toHaveBeenCalledWith(spaceIds, actions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when checking a single namespace', () => {
|
||||
|
|
|
@ -14,9 +14,13 @@ export type CheckSavedObjectsPrivilegesWithRequest = (
|
|||
|
||||
export type CheckSavedObjectsPrivileges = (
|
||||
actions: string | string[],
|
||||
namespaceOrNamespaces?: string | string[]
|
||||
namespaceOrNamespaces?: string | Array<undefined | string>
|
||||
) => Promise<CheckPrivilegesResponse>;
|
||||
|
||||
function uniq<T>(arr: T[]): T[] {
|
||||
return Array.from(new Set<T>(arr));
|
||||
}
|
||||
|
||||
export const checkSavedObjectsPrivilegesWithRequestFactory = (
|
||||
checkPrivilegesWithRequest: CheckPrivilegesWithRequest,
|
||||
getSpacesService: () => SpacesService | undefined
|
||||
|
@ -26,7 +30,7 @@ export const checkSavedObjectsPrivilegesWithRequestFactory = (
|
|||
): CheckSavedObjectsPrivileges {
|
||||
return async function checkSavedObjectsPrivileges(
|
||||
actions: string | string[],
|
||||
namespaceOrNamespaces?: string | string[]
|
||||
namespaceOrNamespaces?: string | Array<undefined | string>
|
||||
) {
|
||||
const spacesService = getSpacesService();
|
||||
if (!spacesService) {
|
||||
|
@ -37,7 +41,10 @@ export const checkSavedObjectsPrivilegesWithRequestFactory = (
|
|||
if (!namespaceOrNamespaces.length) {
|
||||
throw new Error(`Can't check saved object privileges for 0 namespaces`);
|
||||
}
|
||||
const spaceIds = namespaceOrNamespaces.map((x) => spacesService.namespaceToSpaceId(x));
|
||||
const spaceIds = uniq(
|
||||
namespaceOrNamespaces.map((x) => spacesService.namespaceToSpaceId(x))
|
||||
);
|
||||
|
||||
return await checkPrivilegesWithRequest(request).atSpaces(spaceIds, actions);
|
||||
} else {
|
||||
// Spaces enabled, authorizing against a single space
|
||||
|
|
|
@ -117,7 +117,11 @@ const expectSuccess = async (fn: Function, args: Record<string, any>, action?: s
|
|||
return result;
|
||||
};
|
||||
|
||||
const expectPrivilegeCheck = async (fn: Function, args: Record<string, any>) => {
|
||||
const expectPrivilegeCheck = async (
|
||||
fn: Function,
|
||||
args: Record<string, any>,
|
||||
namespacesOverride?: Array<undefined | string>
|
||||
) => {
|
||||
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation(
|
||||
getMockCheckPrivilegesFailure
|
||||
);
|
||||
|
@ -131,7 +135,7 @@ const expectPrivilegeCheck = async (fn: Function, args: Record<string, any>) =>
|
|||
expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(1);
|
||||
expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith(
|
||||
actions,
|
||||
args.options?.namespace ?? args.options?.namespaces
|
||||
namespacesOverride ?? args.options?.namespace ?? args.options?.namespaces
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -483,7 +487,18 @@ describe('#bulkUpdate', () => {
|
|||
|
||||
test(`checks privileges for user, actions, and namespace`, async () => {
|
||||
const objects = [obj1, obj2];
|
||||
await expectPrivilegeCheck(client.bulkUpdate, { objects, options });
|
||||
const namespacesOverride = [options.namespace]; // the bulkCreate function checks privileges as an array
|
||||
await expectPrivilegeCheck(client.bulkUpdate, { objects, options }, namespacesOverride);
|
||||
});
|
||||
|
||||
test(`checks privileges for object namespaces if present`, async () => {
|
||||
const objects = [
|
||||
{ ...obj1, namespace: 'foo-ns' },
|
||||
{ ...obj2, namespace: 'bar-ns' },
|
||||
];
|
||||
const namespacesOverride = [undefined, 'foo-ns', 'bar-ns'];
|
||||
// use the default namespace for the options
|
||||
await expectPrivilegeCheck(client.bulkUpdate, { objects, options: {} }, namespacesOverride);
|
||||
});
|
||||
|
||||
test(`filters namespaces that the user doesn't have access to`, async () => {
|
||||
|
|
|
@ -199,12 +199,16 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
|
|||
objects: Array<SavedObjectsBulkUpdateObject<T>> = [],
|
||||
options: SavedObjectsBaseOptions = {}
|
||||
) {
|
||||
await this.ensureAuthorized(
|
||||
this.getUniqueObjectTypes(objects),
|
||||
'bulk_update',
|
||||
options && options.namespace,
|
||||
{ objects, options }
|
||||
);
|
||||
const objectNamespaces = objects
|
||||
// The repository treats an `undefined` object namespace is treated as the absence of a namespace, falling back to options.namespace;
|
||||
// in this case, filter it out here so we don't accidentally check for privileges in the Default space when we shouldn't be doing so.
|
||||
.filter(({ namespace }) => namespace !== undefined)
|
||||
.map(({ namespace }) => namespace!);
|
||||
const namespaces = [options?.namespace, ...objectNamespaces];
|
||||
await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_update', namespaces, {
|
||||
objects,
|
||||
options,
|
||||
});
|
||||
|
||||
const response = await this.baseClient.bulkUpdate<T>(objects, options);
|
||||
return await this.redactSavedObjectsNamespaces(response);
|
||||
|
@ -212,7 +216,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
|
|||
|
||||
private async checkPrivileges(
|
||||
actions: string | string[],
|
||||
namespaceOrNamespaces?: string | string[]
|
||||
namespaceOrNamespaces?: string | Array<undefined | string>
|
||||
) {
|
||||
try {
|
||||
return await this.checkSavedObjectsPrivilegesAsCurrentUser(actions, namespaceOrNamespaces);
|
||||
|
@ -224,7 +228,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
|
|||
private async ensureAuthorized(
|
||||
typeOrTypes: string | string[],
|
||||
action: string,
|
||||
namespaceOrNamespaces?: string | string[],
|
||||
namespaceOrNamespaces?: string | Array<undefined | string>,
|
||||
args?: Record<string, unknown>,
|
||||
auditAction: string = action,
|
||||
requiresAll = true
|
||||
|
|
|
@ -20,6 +20,7 @@ import { copySavedObjectsToSpacesFactory } from './copy_to_spaces';
|
|||
|
||||
jest.mock('../../../../../../src/core/server', () => {
|
||||
return {
|
||||
...(jest.requireActual('../../../../../../src/core/server') as Record<string, unknown>),
|
||||
exportSavedObjectsToStream: jest.fn(),
|
||||
importSavedObjectsFromStream: jest.fn(),
|
||||
};
|
||||
|
|
|
@ -20,6 +20,7 @@ import { resolveCopySavedObjectsToSpacesConflictsFactory } from './resolve_copy_
|
|||
|
||||
jest.mock('../../../../../../src/core/server', () => {
|
||||
return {
|
||||
...(jest.requireActual('../../../../../../src/core/server') as Record<string, unknown>),
|
||||
exportSavedObjectsToStream: jest.fn(),
|
||||
resolveSavedObjectsImportErrors: jest.fn(),
|
||||
};
|
||||
|
|
16
x-pack/plugins/spaces/server/lib/utils/__mocks__/index.ts
Normal file
16
x-pack/plugins/spaces/server/lib/utils/__mocks__/index.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const mockNamespaceIdToString = jest.fn();
|
||||
const mockNamespaceStringToId = jest.fn();
|
||||
jest.mock('../../../../../../../src/core/server', () => ({
|
||||
SavedObjectsUtils: {
|
||||
namespaceIdToString: mockNamespaceIdToString,
|
||||
namespaceStringToId: mockNamespaceStringToId,
|
||||
},
|
||||
}));
|
||||
|
||||
export { mockNamespaceIdToString, mockNamespaceStringToId };
|
|
@ -4,45 +4,29 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { DEFAULT_SPACE_ID } from '../../../common/constants';
|
||||
import { mockNamespaceIdToString, mockNamespaceStringToId } from './__mocks__';
|
||||
import { spaceIdToNamespace, namespaceToSpaceId } from './namespace';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('#spaceIdToNamespace', () => {
|
||||
it('converts the default space to undefined', () => {
|
||||
expect(spaceIdToNamespace(DEFAULT_SPACE_ID)).toBeUndefined();
|
||||
});
|
||||
it('returns result of namespaceStringToId', () => {
|
||||
mockNamespaceStringToId.mockReturnValue('bar');
|
||||
|
||||
it('returns non-default spaces as-is', () => {
|
||||
expect(spaceIdToNamespace('foo')).toEqual('foo');
|
||||
});
|
||||
|
||||
it('throws an error when a spaceId is not provided', () => {
|
||||
// @ts-ignore ts knows this isn't right
|
||||
expect(() => spaceIdToNamespace()).toThrowErrorMatchingInlineSnapshot(`"spaceId is required"`);
|
||||
|
||||
// @ts-ignore ts knows this isn't right
|
||||
expect(() => spaceIdToNamespace(null)).toThrowErrorMatchingInlineSnapshot(
|
||||
`"spaceId is required"`
|
||||
);
|
||||
|
||||
expect(() => spaceIdToNamespace('')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"spaceId is required"`
|
||||
);
|
||||
const result = spaceIdToNamespace('foo');
|
||||
expect(mockNamespaceStringToId).toHaveBeenCalledWith('foo');
|
||||
expect(result).toEqual('bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#namespaceToSpaceId', () => {
|
||||
it('returns the default space id for undefined namespaces', () => {
|
||||
expect(namespaceToSpaceId(undefined)).toEqual(DEFAULT_SPACE_ID);
|
||||
});
|
||||
it('returns result of namespaceIdToString', () => {
|
||||
mockNamespaceIdToString.mockReturnValue('bar');
|
||||
|
||||
it('returns all other namespaces as-is', () => {
|
||||
expect(namespaceToSpaceId('foo')).toEqual('foo');
|
||||
});
|
||||
|
||||
it('throws an error when an empty string is provided', () => {
|
||||
expect(() => namespaceToSpaceId('')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"namespace cannot be an empty string"`
|
||||
);
|
||||
const result = namespaceToSpaceId('foo');
|
||||
expect(mockNamespaceIdToString).toHaveBeenCalledWith('foo');
|
||||
expect(result).toEqual('bar');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,28 +4,22 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { DEFAULT_SPACE_ID } from '../../../common/constants';
|
||||
import { SavedObjectsUtils } from '../../../../../../src/core/server';
|
||||
|
||||
export function spaceIdToNamespace(spaceId: string): string | undefined {
|
||||
if (!spaceId) {
|
||||
throw new TypeError('spaceId is required');
|
||||
}
|
||||
|
||||
if (spaceId === DEFAULT_SPACE_ID) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return spaceId;
|
||||
/**
|
||||
* Converts a Space ID string to its namespace ID representation. Note that a Space ID string is equivalent to a namespace string.
|
||||
*
|
||||
* See also: {@link namespaceStringToId}.
|
||||
*/
|
||||
export function spaceIdToNamespace(spaceId: string) {
|
||||
return SavedObjectsUtils.namespaceStringToId(spaceId);
|
||||
}
|
||||
|
||||
export function namespaceToSpaceId(namespace: string | undefined): string {
|
||||
if (namespace === '') {
|
||||
throw new TypeError('namespace cannot be an empty string');
|
||||
}
|
||||
|
||||
if (!namespace) {
|
||||
return DEFAULT_SPACE_ID;
|
||||
}
|
||||
|
||||
return namespace;
|
||||
/**
|
||||
* Converts a namespace ID to its Space ID string representation. Note that a Space ID string is equivalent to a namespace string.
|
||||
*
|
||||
* See also: {@link namespaceIdToString}.
|
||||
*/
|
||||
export function namespaceToSpaceId(namespace?: string) {
|
||||
return SavedObjectsUtils.namespaceIdToString(namespace);
|
||||
}
|
||||
|
|
|
@ -30,10 +30,10 @@ import { securityMock } from '../../../../../security/server/mocks';
|
|||
import { ObjectType } from '@kbn/config-schema';
|
||||
jest.mock('../../../../../../../src/core/server', () => {
|
||||
return {
|
||||
...(jest.requireActual('../../../../../../../src/core/server') as Record<string, unknown>),
|
||||
exportSavedObjectsToStream: jest.fn(),
|
||||
importSavedObjectsFromStream: jest.fn(),
|
||||
resolveSavedObjectsImportErrors: jest.fn(),
|
||||
kibanaResponseFactory: jest.requireActual('src/core/server').kibanaResponseFactory,
|
||||
};
|
||||
});
|
||||
import {
|
||||
|
|
|
@ -8,12 +8,7 @@ import expect from '@kbn/expect';
|
|||
import { SuperTest } from 'supertest';
|
||||
import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases';
|
||||
import { SPACES } from '../lib/spaces';
|
||||
import {
|
||||
createRequest,
|
||||
expectResponses,
|
||||
getUrlPrefix,
|
||||
getTestTitle,
|
||||
} from '../lib/saved_object_test_utils';
|
||||
import { expectResponses, getUrlPrefix, getTestTitle } from '../lib/saved_object_test_utils';
|
||||
import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types';
|
||||
|
||||
export interface BulkUpdateTestDefinition extends TestDefinition {
|
||||
|
@ -21,6 +16,7 @@ export interface BulkUpdateTestDefinition extends TestDefinition {
|
|||
}
|
||||
export type BulkUpdateTestSuite = TestSuite<BulkUpdateTestDefinition>;
|
||||
export interface BulkUpdateTestCase extends TestCase {
|
||||
namespace?: string; // used to define individual "object namespace" strings, e.g., bulkUpdate across multiple namespaces
|
||||
failure?: 404; // only used for permitted response case
|
||||
}
|
||||
|
||||
|
@ -30,6 +26,12 @@ const NEW_ATTRIBUTE_VAL = `Updated attribute value ${Date.now()}`;
|
|||
const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' });
|
||||
export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST });
|
||||
|
||||
const createRequest = ({ type, id, namespace }: BulkUpdateTestCase) => ({
|
||||
type,
|
||||
id,
|
||||
...(namespace && { namespace }), // individual "object namespace" string
|
||||
});
|
||||
|
||||
export function bulkUpdateTestSuiteFactory(esArchiver: any, supertest: SuperTest<any>) {
|
||||
const expectForbidden = expectResponses.forbiddenTypes('bulk_update');
|
||||
const expectResponseBody = (
|
||||
|
|
|
@ -39,7 +39,18 @@ const createTestCases = (spaceId: string) => {
|
|||
];
|
||||
const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }];
|
||||
const allTypes = normalTypes.concat(hiddenType);
|
||||
return { normalTypes, hiddenType, allTypes };
|
||||
// an "object namespace" string can be specified for individual objects (to bulkUpdate across namespaces)
|
||||
const withObjectNamespaces = [
|
||||
{ ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, namespace: DEFAULT_SPACE_ID },
|
||||
{ ...CASES.SINGLE_NAMESPACE_SPACE_1, namespace: SPACE_1_ID },
|
||||
{ ...CASES.SINGLE_NAMESPACE_SPACE_2, namespace: SPACE_1_ID, ...fail404() }, // intentional 404 test case
|
||||
{ ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, namespace: DEFAULT_SPACE_ID }, // SPACE_1_ID would also work
|
||||
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, namespace: SPACE_2_ID, ...fail404() }, // intentional 404 test case
|
||||
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, namespace: SPACE_2_ID },
|
||||
CASES.NAMESPACE_AGNOSTIC, // any namespace would work and would make no difference
|
||||
{ ...CASES.DOES_NOT_EXIST, ...fail404() },
|
||||
];
|
||||
return { normalTypes, hiddenType, allTypes, withObjectNamespaces };
|
||||
};
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
|
@ -51,26 +62,42 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
supertest
|
||||
);
|
||||
const createTests = (spaceId: string) => {
|
||||
const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId);
|
||||
const { normalTypes, hiddenType, allTypes, withObjectNamespaces } = createTestCases(spaceId);
|
||||
// use singleRequest to reduce execution time and/or test combined cases
|
||||
const authorizedCommon = [
|
||||
createTestDefinitions(normalTypes, false, { singleRequest: true }),
|
||||
createTestDefinitions(hiddenType, true),
|
||||
createTestDefinitions(allTypes, true, {
|
||||
singleRequest: true,
|
||||
responseBodyOverride: expectForbidden(['hiddentype']),
|
||||
}),
|
||||
].flat();
|
||||
return {
|
||||
unauthorized: createTestDefinitions(allTypes, true),
|
||||
authorized: [
|
||||
createTestDefinitions(normalTypes, false, { singleRequest: true }),
|
||||
createTestDefinitions(hiddenType, true),
|
||||
createTestDefinitions(allTypes, true, {
|
||||
singleRequest: true,
|
||||
responseBodyOverride: expectForbidden(['hiddentype']),
|
||||
}),
|
||||
unauthorized: [
|
||||
createTestDefinitions(allTypes, true),
|
||||
createTestDefinitions(withObjectNamespaces, true, { singleRequest: true }),
|
||||
].flat(),
|
||||
authorizedAtSpace: [
|
||||
authorizedCommon,
|
||||
createTestDefinitions(withObjectNamespaces, true, { singleRequest: true }),
|
||||
].flat(),
|
||||
authorizedAllSpaces: [
|
||||
authorizedCommon,
|
||||
createTestDefinitions(withObjectNamespaces, false, { singleRequest: true }),
|
||||
].flat(),
|
||||
superuser: [
|
||||
createTestDefinitions(allTypes, false, { singleRequest: true }),
|
||||
createTestDefinitions(withObjectNamespaces, false, { singleRequest: true }),
|
||||
].flat(),
|
||||
superuser: createTestDefinitions(allTypes, false, { singleRequest: true }),
|
||||
};
|
||||
};
|
||||
|
||||
describe('_bulk_update', () => {
|
||||
getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => {
|
||||
const suffix = ` within the ${spaceId} space`;
|
||||
const { unauthorized, authorized, superuser } = createTests(spaceId);
|
||||
const { unauthorized, authorizedAtSpace, authorizedAllSpaces, superuser } = createTests(
|
||||
spaceId
|
||||
);
|
||||
const _addTests = (user: TestUser, tests: BulkUpdateTestDefinition[]) => {
|
||||
addTests(`${user.description}${suffix}`, { user, spaceId, tests });
|
||||
};
|
||||
|
@ -85,8 +112,11 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
].forEach((user) => {
|
||||
_addTests(user, unauthorized);
|
||||
});
|
||||
[users.dualAll, users.allGlobally, users.allAtSpace].forEach((user) => {
|
||||
_addTests(user, authorized);
|
||||
[users.allAtSpace].forEach((user) => {
|
||||
_addTests(user, authorizedAtSpace);
|
||||
});
|
||||
[users.dualAll, users.allGlobally].forEach((user) => {
|
||||
_addTests(user, authorizedAllSpaces);
|
||||
});
|
||||
_addTests(users.superuser, superuser);
|
||||
});
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { SPACES } from '../../common/lib/spaces';
|
||||
import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils';
|
||||
import { TestUser } from '../../common/lib/types';
|
||||
import { FtrProviderContext } from '../../common/ftr_provider_context';
|
||||
|
@ -13,6 +14,11 @@ import {
|
|||
BulkUpdateTestDefinition,
|
||||
} from '../../common/suites/bulk_update';
|
||||
|
||||
const {
|
||||
DEFAULT: { spaceId: DEFAULT_SPACE_ID },
|
||||
SPACE_1: { spaceId: SPACE_1_ID },
|
||||
SPACE_2: { spaceId: SPACE_2_ID },
|
||||
} = SPACES;
|
||||
const { fail404 } = testCaseFailures;
|
||||
|
||||
const createTestCases = () => {
|
||||
|
@ -30,7 +36,19 @@ const createTestCases = () => {
|
|||
];
|
||||
const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }];
|
||||
const allTypes = normalTypes.concat(hiddenType);
|
||||
return { normalTypes, hiddenType, allTypes };
|
||||
// an "object namespace" string can be specified for individual objects (to bulkUpdate across namespaces)
|
||||
// even if the Spaces plugin is disabled, this should work, as `namespace` is handled by the Core API
|
||||
const withObjectNamespaces = [
|
||||
{ ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, namespace: DEFAULT_SPACE_ID },
|
||||
{ ...CASES.SINGLE_NAMESPACE_SPACE_1, namespace: SPACE_1_ID },
|
||||
{ ...CASES.SINGLE_NAMESPACE_SPACE_2, namespace: SPACE_1_ID, ...fail404() }, // intentional 404 test case
|
||||
{ ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, namespace: DEFAULT_SPACE_ID }, // SPACE_1_ID would also work
|
||||
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, namespace: SPACE_2_ID, ...fail404() }, // intentional 404 test case
|
||||
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, namespace: SPACE_2_ID },
|
||||
CASES.NAMESPACE_AGNOSTIC, // any namespace would work and would make no difference
|
||||
{ ...CASES.DOES_NOT_EXIST, ...fail404() },
|
||||
];
|
||||
return { normalTypes, hiddenType, allTypes, withObjectNamespaces };
|
||||
};
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
|
@ -42,10 +60,13 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
supertest
|
||||
);
|
||||
const createTests = () => {
|
||||
const { normalTypes, hiddenType, allTypes } = createTestCases();
|
||||
const { normalTypes, hiddenType, allTypes, withObjectNamespaces } = createTestCases();
|
||||
// use singleRequest to reduce execution time and/or test combined cases
|
||||
return {
|
||||
unauthorized: createTestDefinitions(allTypes, true),
|
||||
unauthorized: [
|
||||
createTestDefinitions(allTypes, true),
|
||||
createTestDefinitions(withObjectNamespaces, true, { singleRequest: true }),
|
||||
].flat(),
|
||||
authorized: [
|
||||
createTestDefinitions(normalTypes, false, { singleRequest: true }),
|
||||
createTestDefinitions(hiddenType, true),
|
||||
|
@ -53,8 +74,12 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
singleRequest: true,
|
||||
responseBodyOverride: expectForbidden(['hiddentype']),
|
||||
}),
|
||||
createTestDefinitions(withObjectNamespaces, false, { singleRequest: true }),
|
||||
].flat(),
|
||||
superuser: [
|
||||
createTestDefinitions(allTypes, false, { singleRequest: true }),
|
||||
createTestDefinitions(withObjectNamespaces, false, { singleRequest: true }),
|
||||
].flat(),
|
||||
superuser: createTestDefinitions(allTypes, false, { singleRequest: true }),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -16,22 +16,37 @@ const {
|
|||
} = SPACES;
|
||||
const { fail404 } = testCaseFailures;
|
||||
|
||||
const createTestCases = (spaceId: string) => [
|
||||
const createTestCases = (spaceId: string) => {
|
||||
// for each outcome, if failure !== undefined then we expect to receive
|
||||
// an error; otherwise, we expect to receive a success result
|
||||
{ ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) },
|
||||
{ ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) },
|
||||
{ ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) },
|
||||
{
|
||||
...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1,
|
||||
...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID),
|
||||
},
|
||||
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) },
|
||||
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) },
|
||||
CASES.NAMESPACE_AGNOSTIC,
|
||||
{ ...CASES.HIDDEN, ...fail404() },
|
||||
{ ...CASES.DOES_NOT_EXIST, ...fail404() },
|
||||
];
|
||||
const normal = [
|
||||
{ ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) },
|
||||
{ ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) },
|
||||
{ ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) },
|
||||
{
|
||||
...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1,
|
||||
...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID),
|
||||
},
|
||||
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) },
|
||||
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) },
|
||||
CASES.NAMESPACE_AGNOSTIC,
|
||||
{ ...CASES.HIDDEN, ...fail404() },
|
||||
{ ...CASES.DOES_NOT_EXIST, ...fail404() },
|
||||
];
|
||||
|
||||
// an "object namespace" string can be specified for individual objects (to bulkUpdate across namespaces)
|
||||
const withObjectNamespaces = [
|
||||
{ ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, namespace: DEFAULT_SPACE_ID },
|
||||
{ ...CASES.SINGLE_NAMESPACE_SPACE_1, namespace: SPACE_1_ID },
|
||||
{ ...CASES.SINGLE_NAMESPACE_SPACE_2, namespace: SPACE_1_ID, ...fail404() }, // intentional 404 test case
|
||||
{ ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, namespace: DEFAULT_SPACE_ID }, // SPACE_1_ID would also work
|
||||
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, namespace: SPACE_2_ID, ...fail404() }, // intentional 404 test case
|
||||
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, namespace: SPACE_2_ID },
|
||||
CASES.NAMESPACE_AGNOSTIC, // any namespace would work and would make no difference
|
||||
{ ...CASES.DOES_NOT_EXIST, ...fail404() },
|
||||
];
|
||||
return { normal, withObjectNamespaces };
|
||||
};
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
@ -39,8 +54,11 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
|
||||
const { addTests, createTestDefinitions } = bulkUpdateTestSuiteFactory(esArchiver, supertest);
|
||||
const createTests = (spaceId: string) => {
|
||||
const testCases = createTestCases(spaceId);
|
||||
return createTestDefinitions(testCases, false, { singleRequest: true });
|
||||
const { normal, withObjectNamespaces } = createTestCases(spaceId);
|
||||
return [
|
||||
createTestDefinitions(normal, false, { singleRequest: true }),
|
||||
createTestDefinitions(withObjectNamespaces, false, { singleRequest: true }),
|
||||
].flat();
|
||||
};
|
||||
|
||||
describe('_bulk_update', () => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue