diff --git a/docs/api/saved-objects/bulk_create.asciidoc b/docs/api/saved-objects/bulk_create.asciidoc index 9daba224b317c3..fbd4c6e77f8bfd 100644 --- a/docs/api/saved-objects/bulk_create.asciidoc +++ b/docs/api/saved-objects/bulk_create.asciidoc @@ -104,7 +104,7 @@ The API returns the following: "type": "dashboard", "error": { "statusCode": 409, - "message": "version conflict, document already exists" + "message": "Saved object [dashboard/be3733a0-9efe-11e7-acb3-3dab96693fab] conflict" } } ] diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.md index c6bc13b98bc066..b67d0536fb3365 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobject.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.md @@ -18,6 +18,7 @@ export interface SavedObject | [error](./kibana-plugin-core-public.savedobject.error.md) | {
message: string;
statusCode: number;
} | | | [id](./kibana-plugin-core-public.savedobject.id.md) | string | The ID of this Saved Object, guaranteed to be unique for all objects of the same type | | [migrationVersion](./kibana-plugin-core-public.savedobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [namespaces](./kibana-plugin-core-public.savedobject.namespaces.md) | string[] | Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. | | [references](./kibana-plugin-core-public.savedobject.references.md) | SavedObjectReference[] | A reference to another saved object. | | [type](./kibana-plugin-core-public.savedobject.type.md) | string | The type of Saved Object. Each plugin can define it's own custom Saved Object types. | | [updated\_at](./kibana-plugin-core-public.savedobject.updated_at.md) | string | Timestamp of the last time this document had been updated. | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.namespaces.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.namespaces.md new file mode 100644 index 00000000000000..257df459345068 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.namespaces.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObject](./kibana-plugin-core-public.savedobject.md) > [namespaces](./kibana-plugin-core-public.savedobject.namespaces.md) + +## SavedObject.namespaces property + +Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. + +Signature: + +```typescript +namespaces?: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.isavedobjecttyperegistry.md b/docs/development/core/server/kibana-plugin-core-server.isavedobjecttyperegistry.md index 245cb1a56439fd..f9c621885c0019 100644 --- a/docs/development/core/server/kibana-plugin-core-server.isavedobjecttyperegistry.md +++ b/docs/development/core/server/kibana-plugin-core-server.isavedobjecttyperegistry.md @@ -9,5 +9,5 @@ See [SavedObjectTypeRegistry](./kibana-plugin-core-server.savedobjecttyperegistr Signature: ```typescript -export declare type ISavedObjectTypeRegistry = Pick; +export declare type ISavedObjectTypeRegistry = Omit; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index accab9bf0cb360..5c0f10cac51793 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -130,6 +130,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectMigrationContext](./kibana-plugin-core-server.savedobjectmigrationcontext.md) | Migration context provided when invoking a [migration handler](./kibana-plugin-core-server.savedobjectmigrationfn.md) | | [SavedObjectMigrationMap](./kibana-plugin-core-server.savedobjectmigrationmap.md) | A map of [migration functions](./kibana-plugin-core-server.savedobjectmigrationfn.md) to be used for a given type. The map's keys must be valid semver versions.For a given document, only migrations with a higher version number than that of the document will be applied. Migrations are executed in order, starting from the lowest version and ending with the highest one. | | [SavedObjectReference](./kibana-plugin-core-server.savedobjectreference.md) | A reference to another saved object. | +| [SavedObjectsAddToNamespacesOptions](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md) | | | [SavedObjectsBaseOptions](./kibana-plugin-core-server.savedobjectsbaseoptions.md) | | | [SavedObjectsBulkCreateObject](./kibana-plugin-core-server.savedobjectsbulkcreateobject.md) | | | [SavedObjectsBulkGetObject](./kibana-plugin-core-server.savedobjectsbulkgetobject.md) | | @@ -143,6 +144,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsCoreFieldMapping](./kibana-plugin-core-server.savedobjectscorefieldmapping.md) | See [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) for documentation. | | [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) | | | [SavedObjectsDeleteByNamespaceOptions](./kibana-plugin-core-server.savedobjectsdeletebynamespaceoptions.md) | | +| [SavedObjectsDeleteFromNamespacesOptions](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md) | | | [SavedObjectsDeleteOptions](./kibana-plugin-core-server.savedobjectsdeleteoptions.md) | | | [SavedObjectsExportOptions](./kibana-plugin-core-server.savedobjectsexportoptions.md) | Options controlling the export operation. | | [SavedObjectsExportResultDetails](./kibana-plugin-core-server.savedobjectsexportresultdetails.md) | Structure of the export result details entry | @@ -262,6 +264,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsClientFactoryProvider](./kibana-plugin-core-server.savedobjectsclientfactoryprovider.md) | Provider to invoke to retrieve a [SavedObjectsClientFactory](./kibana-plugin-core-server.savedobjectsclientfactory.md). | | [SavedObjectsClientWrapperFactory](./kibana-plugin-core-server.savedobjectsclientwrapperfactory.md) | Describes the factory used to create instances of Saved Objects Client Wrappers. | | [SavedObjectsFieldMapping](./kibana-plugin-core-server.savedobjectsfieldmapping.md) | Describe a [saved object type mapping](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) field.Please refer to [elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html) For the mapping documentation | +| [SavedObjectsNamespaceType](./kibana-plugin-core-server.savedobjectsnamespacetype.md) | The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global.Note: do not write logic that uses this value directly; instead, use the appropriate accessors in the [type registry](./kibana-plugin-core-server.savedobjecttyperegistry.md). | | [ScopeableRequest](./kibana-plugin-core-server.scopeablerequest.md) | A user credentials container. It accommodates the necessary auth credentials to impersonate the current user.See [KibanaRequest](./kibana-plugin-core-server.kibanarequest.md). | | [ServiceStatusLevel](./kibana-plugin-core-server.servicestatuslevel.md) | A convenience type that represents the union of each value in [ServiceStatusLevels](./kibana-plugin-core-server.servicestatuslevels.md). | | [SharedGlobalConfig](./kibana-plugin-core-server.sharedglobalconfig.md) | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.md index 0df97b0d4221a2..94d1c378899dfb 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.md @@ -18,6 +18,7 @@ export interface SavedObject | [error](./kibana-plugin-core-server.savedobject.error.md) | {
message: string;
statusCode: number;
} | | | [id](./kibana-plugin-core-server.savedobject.id.md) | string | The ID of this Saved Object, guaranteed to be unique for all objects of the same type | | [migrationVersion](./kibana-plugin-core-server.savedobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [namespaces](./kibana-plugin-core-server.savedobject.namespaces.md) | string[] | Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. | | [references](./kibana-plugin-core-server.savedobject.references.md) | SavedObjectReference[] | A reference to another saved object. | | [type](./kibana-plugin-core-server.savedobject.type.md) | string | The type of Saved Object. Each plugin can define it's own custom Saved Object types. | | [updated\_at](./kibana-plugin-core-server.savedobject.updated_at.md) | string | Timestamp of the last time this document had been updated. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.namespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.namespaces.md new file mode 100644 index 00000000000000..2a555db01df3b8 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.namespaces.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObject](./kibana-plugin-core-server.savedobject.md) > [namespaces](./kibana-plugin-core-server.savedobject.namespaces.md) + +## SavedObject.namespaces property + +Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. + +Signature: + +```typescript +namespaces?: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md new file mode 100644 index 00000000000000..711588bdd608cd --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsAddToNamespacesOptions](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md) + +## SavedObjectsAddToNamespacesOptions interface + + +Signature: + +```typescript +export interface SavedObjectsAddToNamespacesOptions extends SavedObjectsBaseOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [refresh](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.refresh.md) | MutatingOperationRefreshSetting | The Elasticsearch Refresh setting for this operation | +| [version](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.version.md) | string | An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.refresh.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.refresh.md new file mode 100644 index 00000000000000..c0a1008ab53316 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.refresh.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsAddToNamespacesOptions](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md) > [refresh](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.refresh.md) + +## SavedObjectsAddToNamespacesOptions.refresh property + +The Elasticsearch Refresh setting for this operation + +Signature: + +```typescript +refresh?: MutatingOperationRefreshSetting; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.version.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.version.md new file mode 100644 index 00000000000000..9432b4bf80da6c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.version.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsAddToNamespacesOptions](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.md) > [version](./kibana-plugin-core-server.savedobjectsaddtonamespacesoptions.version.md) + +## SavedObjectsAddToNamespacesOptions.version property + +An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. + +Signature: + +```typescript +version?: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.addtonamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.addtonamespaces.md new file mode 100644 index 00000000000000..45c9c39f9626a1 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.addtonamespaces.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [addToNamespaces](./kibana-plugin-core-server.savedobjectsclient.addtonamespaces.md) + +## SavedObjectsClient.addToNamespaces() method + +Adds namespaces to a SavedObject + +Signature: + +```typescript +addToNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsAddToNamespacesOptions): Promise<{}>; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | +| id | string | | +| namespaces | string[] | | +| options | SavedObjectsAddToNamespacesOptions | | + +Returns: + +`Promise<{}>` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md new file mode 100644 index 00000000000000..80b58d29d393b3 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) > [deleteFromNamespaces](./kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md) + +## SavedObjectsClient.deleteFromNamespaces() method + +Removes namespaces from a SavedObject + +Signature: + +```typescript +deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise<{}>; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | +| id | string | | +| namespaces | string[] | | +| options | SavedObjectsDeleteFromNamespacesOptions | | + +Returns: + +`Promise<{}>` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md index 5a8a213d2bccc3..7038c0c07012fb 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.md @@ -25,11 +25,13 @@ The constructor for this class is marked as internal. Third-party code should no | Method | Modifiers | Description | | --- | --- | --- | +| [addToNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsclient.addtonamespaces.md) | | Adds namespaces to a SavedObject | | [bulkCreate(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkcreate.md) | | Persists multiple documents batched together as a single request | | [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkget.md) | | Returns an array of objects by id | | [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsclient.bulkupdate.md) | | Bulk Updates multiple SavedObject at once | | [create(type, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.create.md) | | Persists a SavedObject | | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.delete.md) | | Deletes a SavedObject | +| [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsclient.deletefromnamespaces.md) | | Removes namespaces from a SavedObject | | [find(options)](./kibana-plugin-core-server.savedobjectsclient.find.md) | | Find all SavedObjects matching the search query | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsclient.get.md) | | Retrieves a single object | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsclient.update.md) | | Updates an SavedObject | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md new file mode 100644 index 00000000000000..8a2afe6656fa4c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsDeleteFromNamespacesOptions](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md) + +## SavedObjectsDeleteFromNamespacesOptions interface + + +Signature: + +```typescript +export interface SavedObjectsDeleteFromNamespacesOptions extends SavedObjectsBaseOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [refresh](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.refresh.md) | MutatingOperationRefreshSetting | The Elasticsearch Refresh setting for this operation | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.refresh.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.refresh.md new file mode 100644 index 00000000000000..1175b79bc1abdf --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.refresh.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsDeleteFromNamespacesOptions](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.md) > [refresh](./kibana-plugin-core-server.savedobjectsdeletefromnamespacesoptions.refresh.md) + +## SavedObjectsDeleteFromNamespacesOptions.refresh property + +The Elasticsearch Refresh setting for this operation + +Signature: + +```typescript +refresh?: MutatingOperationRefreshSetting; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md new file mode 100644 index 00000000000000..8e04282ce0c71f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) > [createConflictError](./kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md) + +## SavedObjectsErrorHelpers.createConflictError() method + +Signature: + +```typescript +static createConflictError(type: string, id: string): DecoratedError; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | +| id | string | | + +Returns: + +`DecoratedError` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.decorateescannotexecutescripterror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.decorateescannotexecutescripterror.md new file mode 100644 index 00000000000000..94060bba500670 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.decorateescannotexecutescripterror.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) > [decorateEsCannotExecuteScriptError](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateescannotexecutescripterror.md) + +## SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError() method + +Signature: + +```typescript +static decorateEsCannotExecuteScriptError(error: Error, reason?: string): DecoratedError; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| error | Error | | +| reason | string | | + +Returns: + +`DecoratedError` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.isescannotexecutescripterror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.isescannotexecutescripterror.md new file mode 100644 index 00000000000000..debb94fe4f8d83 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.isescannotexecutescripterror.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) > [isEsCannotExecuteScriptError](./kibana-plugin-core-server.savedobjectserrorhelpers.isescannotexecutescripterror.md) + +## SavedObjectsErrorHelpers.isEsCannotExecuteScriptError() method + +Signature: + +```typescript +static isEsCannotExecuteScriptError(error: Error | DecoratedError): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| error | Error | DecoratedError | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md index 55a42e4f4eb7af..250b9d38996702 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md @@ -16,12 +16,14 @@ export declare class SavedObjectsErrorHelpers | Method | Modifiers | Description | | --- | --- | --- | | [createBadRequestError(reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.createbadrequesterror.md) | static | | +| [createConflictError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md) | static | | | [createEsAutoCreateIndexError()](./kibana-plugin-core-server.savedobjectserrorhelpers.createesautocreateindexerror.md) | static | | | [createGenericNotFoundError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.creategenericnotfounderror.md) | static | | | [createInvalidVersionError(versionInput)](./kibana-plugin-core-server.savedobjectserrorhelpers.createinvalidversionerror.md) | static | | | [createUnsupportedTypeError(type)](./kibana-plugin-core-server.savedobjectserrorhelpers.createunsupportedtypeerror.md) | static | | | [decorateBadRequestError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decoratebadrequesterror.md) | static | | | [decorateConflictError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateconflicterror.md) | static | | +| [decorateEsCannotExecuteScriptError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateescannotexecutescripterror.md) | static | | | [decorateEsUnavailableError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateesunavailableerror.md) | static | | | [decorateForbiddenError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorateforbiddenerror.md) | static | | | [decorateGeneralError(error, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.decorategeneralerror.md) | static | | @@ -30,6 +32,7 @@ export declare class SavedObjectsErrorHelpers | [isBadRequestError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isbadrequesterror.md) | static | | | [isConflictError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isconflicterror.md) | static | | | [isEsAutoCreateIndexError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isesautocreateindexerror.md) | static | | +| [isEsCannotExecuteScriptError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isescannotexecutescripterror.md) | static | | | [isEsUnavailableError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isesunavailableerror.md) | static | | | [isForbiddenError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isforbiddenerror.md) | static | | | [isInvalidVersionError(error)](./kibana-plugin-core-server.savedobjectserrorhelpers.isinvalidversionerror.md) | static | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md new file mode 100644 index 00000000000000..173b9e19321d04 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsnamespacetype.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsNamespaceType](./kibana-plugin-core-server.savedobjectsnamespacetype.md) + +## SavedObjectsNamespaceType type + +The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: \* single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. \* multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. \* agnostic: this type of saved object is global. + +Note: do not write logic that uses this value directly; instead, use the appropriate accessors in the [type registry](./kibana-plugin-core-server.savedobjecttyperegistry.md). + +Signature: + +```typescript +export declare type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md new file mode 100644 index 00000000000000..bbb20d2bc3b964 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [addToNamespaces](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) + +## SavedObjectsRepository.addToNamespaces() method + +Adds one or more namespaces to a given multi-namespace saved object. This method and \[`deleteFromNamespaces`\][SavedObjectsRepository.deleteFromNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. + +Signature: + +```typescript +addToNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsAddToNamespacesOptions): Promise<{}>; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | +| id | string | | +| namespaces | string[] | | +| options | SavedObjectsAddToNamespacesOptions | | + +Returns: + +`Promise<{}>` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md new file mode 100644 index 00000000000000..471c3e3c5092d6 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsRepository](./kibana-plugin-core-server.savedobjectsrepository.md) > [deleteFromNamespaces](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) + +## SavedObjectsRepository.deleteFromNamespaces() method + +Removes one or more namespaces from a given multi-namespace saved object. If no namespaces remain, the saved object is deleted entirely. This method and \[`addToNamespaces`\][SavedObjectsRepository.addToNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. + +Signature: + +```typescript +deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise<{}>; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | +| id | string | | +| namespaces | string[] | | +| options | SavedObjectsDeleteFromNamespacesOptions | | + +Returns: + +`Promise<{}>` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md index 37547af2497e94..bd86ff3abbe9b5 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md @@ -15,12 +15,14 @@ export declare class SavedObjectsRepository | Method | Modifiers | Description | | --- | --- | --- | +| [addToNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) | | Adds one or more namespaces to a given multi-namespace saved object. This method and \[deleteFromNamespaces\][SavedObjectsRepository.deleteFromNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. | | [bulkCreate(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkcreate.md) | | Creates multiple documents at once | | [bulkGet(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkget.md) | | Returns an array of objects by id | | [bulkUpdate(objects, options)](./kibana-plugin-core-server.savedobjectsrepository.bulkupdate.md) | | Updates multiple objects in bulk | | [create(type, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.create.md) | | Persists an object | | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.delete.md) | | Deletes an object | | [deleteByNamespace(namespace, options)](./kibana-plugin-core-server.savedobjectsrepository.deletebynamespace.md) | | Deletes all objects from the provided namespace. | +| [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) | | Removes one or more namespaces from a given multi-namespace saved object. If no namespaces remain, the saved object is deleted entirely. This method and \[addToNamespaces\][SavedObjectsRepository.addToNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. | | [find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, })](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.get.md) | | Gets a single object | | [incrementCounter(type, id, counterFieldName, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increases a counter field by one. Creates the document if one doesn't exist for the given id. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md index c24fa7e7038c6b..57c9e04966c1b1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsservicesetup.registertype.md @@ -29,7 +29,7 @@ import * as migrations from './migrations'; export const myType: SavedObjectsType = { name: 'MyType', hidden: false, - namespaceAgnostic: true, + namespaceType: 'multiple', mappings: { properties: { textField: { diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md index ad7bf9a0f00d0c..d8202545f0eae6 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md @@ -25,5 +25,6 @@ This is only internal for now, and will only be public when we expose the regist | [mappings](./kibana-plugin-core-server.savedobjectstype.mappings.md) | SavedObjectsTypeMappingDefinition | The [mapping definition](./kibana-plugin-core-server.savedobjectstypemappingdefinition.md) for the type. | | [migrations](./kibana-plugin-core-server.savedobjectstype.migrations.md) | SavedObjectMigrationMap | An optional map of [migrations](./kibana-plugin-core-server.savedobjectmigrationfn.md) to be used to migrate the type. | | [name](./kibana-plugin-core-server.savedobjectstype.name.md) | string | The name of the type, which is also used as the internal id. | -| [namespaceAgnostic](./kibana-plugin-core-server.savedobjectstype.namespaceagnostic.md) | boolean | Is the type global (true), or namespaced (false). | +| [namespaceAgnostic](./kibana-plugin-core-server.savedobjectstype.namespaceagnostic.md) | boolean | Is the type global (true), or not (false). | +| [namespaceType](./kibana-plugin-core-server.savedobjectstype.namespacetype.md) | SavedObjectsNamespaceType | The [namespace type](./kibana-plugin-core-server.savedobjectsnamespacetype.md) for the type. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.namespaceagnostic.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.namespaceagnostic.md index 8f43db86449d03..e3474215904823 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.namespaceagnostic.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.namespaceagnostic.md @@ -4,10 +4,15 @@ ## SavedObjectsType.namespaceAgnostic property -Is the type global (true), or namespaced (false). +> Warning: This API is now obsolete. +> +> Use `namespaceType` instead. +> + +Is the type global (true), or not (false). Signature: ```typescript -namespaceAgnostic: boolean; +namespaceAgnostic?: boolean; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.namespacetype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.namespacetype.md new file mode 100644 index 00000000000000..69912f9144980d --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.namespacetype.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsType](./kibana-plugin-core-server.savedobjectstype.md) > [namespaceType](./kibana-plugin-core-server.savedobjectstype.namespacetype.md) + +## SavedObjectsType.namespaceType property + +The [namespace type](./kibana-plugin-core-server.savedobjectsnamespacetype.md) for the type. + +Signature: + +```typescript +namespaceType?: SavedObjectsNamespaceType; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md new file mode 100644 index 00000000000000..6532c5251d816f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectTypeRegistry](./kibana-plugin-core-server.savedobjecttyperegistry.md) > [isMultiNamespace](./kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md) + +## SavedObjectTypeRegistry.isMultiNamespace() method + +Returns whether the type is multi-namespace (shareable); resolves to `false` if the type is not registered + +Signature: + +```typescript +isMultiNamespace(type: string): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isnamespaceagnostic.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isnamespaceagnostic.md index 92dfa5465235a7..859c7b9711816f 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isnamespaceagnostic.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isnamespaceagnostic.md @@ -4,7 +4,7 @@ ## SavedObjectTypeRegistry.isNamespaceAgnostic() method -Returns the `namespaceAgnostic` property for given type, or `false` if the type is not registered. +Returns whether the type is namespace-agnostic (global); resolves to `false` if the type is not registered Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.issinglenamespace.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.issinglenamespace.md new file mode 100644 index 00000000000000..18146b2fd6ea17 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.issinglenamespace.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectTypeRegistry](./kibana-plugin-core-server.savedobjecttyperegistry.md) > [isSingleNamespace](./kibana-plugin-core-server.savedobjecttyperegistry.issinglenamespace.md) + +## SavedObjectTypeRegistry.isSingleNamespace() method + +Returns whether the type is single-namespace (isolated); resolves to `true` if the type is not registered + +Signature: + +```typescript +isSingleNamespace(type: string): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md index 410b709252b725..69a94e4ad8c882 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md @@ -22,6 +22,8 @@ export declare class SavedObjectTypeRegistry | [getType(type)](./kibana-plugin-core-server.savedobjecttyperegistry.gettype.md) | | Return the [type](./kibana-plugin-core-server.savedobjectstype.md) definition for given type name. | | [isHidden(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ishidden.md) | | Returns the hidden property for given type, or false if the type is not registered. | | [isImportableAndExportable(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isimportableandexportable.md) | | Returns the management.importableAndExportable property for given type, or false if the type is not registered or does not define a management section. | -| [isNamespaceAgnostic(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isnamespaceagnostic.md) | | Returns the namespaceAgnostic property for given type, or false if the type is not registered. | +| [isMultiNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md) | | Returns whether the type is multi-namespace (shareable); resolves to false if the type is not registered | +| [isNamespaceAgnostic(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isnamespaceagnostic.md) | | Returns whether the type is namespace-agnostic (global); resolves to false if the type is not registered | +| [isSingleNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.issinglenamespace.md) | | Returns whether the type is single-namespace (isolated); resolves to true if the type is not registered | | [registerType(type)](./kibana-plugin-core-server.savedobjecttyperegistry.registertype.md) | | Register a [type](./kibana-plugin-core-server.savedobjectstype.md) inside the registry. A type can only be registered once. subsequent calls with the same type name will throw an error. | diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 9f7f649f1e2a5b..a5aa37becabc28 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -956,6 +956,7 @@ export interface SavedObject { }; id: string; migrationVersion?: SavedObjectsMigrationVersion; + namespaces?: string[]; references: SavedObjectReference[]; type: string; updated_at?: string; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index a298f80f96d8f0..039988fa089680 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -227,6 +227,8 @@ export { SavedObjectsLegacyService, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, + SavedObjectsAddToNamespacesOptions, + SavedObjectsDeleteFromNamespacesOptions, SavedObjectsServiceStart, SavedObjectsServiceSetup, SavedObjectStatusMeta, @@ -242,6 +244,7 @@ export { SavedObjectsMappingProperties, SavedObjectTypeRegistry, ISavedObjectTypeRegistry, + SavedObjectsNamespaceType, SavedObjectsType, SavedObjectsTypeManagementDefinition, SavedObjectMigrationMap, diff --git a/src/core/server/saved_objects/__snapshots__/utils.test.ts.snap b/src/core/server/saved_objects/__snapshots__/utils.test.ts.snap index 5431d2ca478926..7cd0297e578579 100644 --- a/src/core/server/saved_objects/__snapshots__/utils.test.ts.snap +++ b/src/core/server/saved_objects/__snapshots__/utils.test.ts.snap @@ -16,7 +16,7 @@ Array [ }, "migrations": Object {}, "name": "typeA", - "namespaceAgnostic": false, + "namespaceType": "single", }, Object { "convertToAliasScript": undefined, @@ -32,7 +32,7 @@ Array [ }, "migrations": Object {}, "name": "typeB", - "namespaceAgnostic": false, + "namespaceType": "single", }, Object { "convertToAliasScript": undefined, @@ -48,7 +48,7 @@ Array [ }, "migrations": Object {}, "name": "typeC", - "namespaceAgnostic": false, + "namespaceType": "single", }, ] `; @@ -72,7 +72,7 @@ Array [ "2.0.4": [Function], }, "name": "typeA", - "namespaceAgnostic": true, + "namespaceType": "agnostic", }, Object { "convertToAliasScript": "some alias script", @@ -91,7 +91,7 @@ Array [ }, "migrations": Object {}, "name": "typeB", - "namespaceAgnostic": false, + "namespaceType": "single", }, Object { "convertToAliasScript": undefined, @@ -109,7 +109,7 @@ Array [ "1.5.3": [Function], }, "name": "typeC", - "namespaceAgnostic": false, + "namespaceType": "single", }, ] `; @@ -130,7 +130,23 @@ Array [ }, "migrations": Object {}, "name": "typeA", - "namespaceAgnostic": true, + "namespaceType": "agnostic", + }, + Object { + "convertToAliasScript": undefined, + "hidden": false, + "indexPattern": "barBaz", + "management": undefined, + "mappings": Object { + "properties": Object { + "fieldB": Object { + "type": "text", + }, + }, + }, + "migrations": Object {}, + "name": "typeB", + "namespaceType": "multiple", }, Object { "convertToAliasScript": undefined, @@ -146,7 +162,23 @@ Array [ }, "migrations": Object {}, "name": "typeC", - "namespaceAgnostic": false, + "namespaceType": "single", + }, + Object { + "convertToAliasScript": undefined, + "hidden": false, + "indexPattern": "bazQux", + "management": undefined, + "mappings": Object { + "properties": Object { + "fieldD": Object { + "type": "text", + }, + }, + }, + "migrations": Object {}, + "name": "typeD", + "namespaceType": "agnostic", }, ] `; diff --git a/src/core/server/saved_objects/index.ts b/src/core/server/saved_objects/index.ts index fe4795cad11a5a..a294b28753f7bb 100644 --- a/src/core/server/saved_objects/index.ts +++ b/src/core/server/saved_objects/index.ts @@ -69,6 +69,7 @@ export { } from './migrations'; export { + SavedObjectsNamespaceType, SavedObjectStatusMeta, SavedObjectsType, SavedObjectsTypeManagementDefinition, diff --git a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap index fc26d7e9cf6e9b..bc9a66926e8801 100644 --- a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap +++ b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap @@ -8,6 +8,7 @@ Object { "bbb": "18c78c995965207ed3f6e7fc5c6e55fe", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", + "namespaces": "2f4316de49999235636386fe51dc06c1", "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", @@ -28,6 +29,9 @@ Object { "namespace": Object { "type": "keyword", }, + "namespaces": Object { + "type": "keyword", + }, "references": Object { "properties": Object { "id": Object { @@ -59,6 +63,7 @@ Object { "firstType": "635418ab953d81d93f1190b70a8d3f57", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", + "namespaces": "2f4316de49999235636386fe51dc06c1", "references": "7997cf5a56cc02bdc9c93361bde732b0", "secondType": "72d57924f415fbadb3ee293b67d233ab", "thirdType": "510f1f0adb69830cf8a1c5ce2923ed82", @@ -83,6 +88,9 @@ Object { "namespace": Object { "type": "keyword", }, + "namespaces": Object { + "type": "keyword", + }, "references": Object { "properties": Object { "id": Object { diff --git a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts index 4d1a607414ca6f..418ed95f14e05f 100644 --- a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts +++ b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts @@ -142,6 +142,9 @@ function defaultMapping(): IndexMapping { namespace: { type: 'keyword', }, + namespaces: { + type: 'keyword', + }, updated_at: { type: 'date', }, diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index 1c2d3f501ff80c..19208e6c835967 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -61,6 +61,7 @@ describe('IndexMigrator', () => { foo: '18c78c995965207ed3f6e7fc5c6e55fe', migrationVersion: '4a1746014a75ade3a714e1db5763276f', namespace: '2f4316de49999235636386fe51dc06c1', + namespaces: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -70,6 +71,7 @@ describe('IndexMigrator', () => { foo: { type: 'long' }, migrationVersion: { dynamic: 'true', type: 'object' }, namespace: { type: 'keyword' }, + namespaces: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, references: { @@ -178,6 +180,7 @@ describe('IndexMigrator', () => { foo: '625b32086eb1d1203564cf85062dd22e', migrationVersion: '4a1746014a75ade3a714e1db5763276f', namespace: '2f4316de49999235636386fe51dc06c1', + namespaces: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -188,6 +191,7 @@ describe('IndexMigrator', () => { foo: { type: 'text' }, migrationVersion: { dynamic: 'true', type: 'object' }, namespace: { type: 'keyword' }, + namespaces: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, references: { diff --git a/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap b/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap index 507c0b0d9339fb..3453f3fc803103 100644 --- a/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap +++ b/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap @@ -8,6 +8,7 @@ Object { "bmap": "510f1f0adb69830cf8a1c5ce2923ed82", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", "namespace": "2f4316de49999235636386fe51dc06c1", + "namespaces": "2f4316de49999235636386fe51dc06c1", "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", @@ -36,6 +37,9 @@ Object { "namespace": Object { "type": "keyword", }, + "namespaces": Object { + "type": "keyword", + }, "references": Object { "properties": Object { "id": Object { diff --git a/src/core/server/saved_objects/routes/integration_tests/import.test.ts b/src/core/server/saved_objects/routes/integration_tests/import.test.ts index c72d3e241b8823..c4a03a0e2e7d2c 100644 --- a/src/core/server/saved_objects/routes/integration_tests/import.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/import.test.ts @@ -187,7 +187,7 @@ describe('POST /internal/saved_objects/_import', () => { references: [], error: { statusCode: 409, - message: 'version conflict, document already exists', + message: 'Saved object [index-pattern/my-pattern] conflict', }, }, { diff --git a/src/core/server/saved_objects/saved_objects_service.test.ts b/src/core/server/saved_objects/saved_objects_service.test.ts index 018117776dcc82..819d79803f371e 100644 --- a/src/core/server/saved_objects/saved_objects_service.test.ts +++ b/src/core/server/saved_objects/saved_objects_service.test.ts @@ -138,7 +138,7 @@ describe('SavedObjectsService', () => { const type = { name: 'someType', hidden: false, - namespaceAgnostic: false, + namespaceType: 'single' as 'single', mappings: { properties: {} }, }; setup.registerType(type); @@ -251,7 +251,7 @@ describe('SavedObjectsService', () => { setup.registerType({ name: 'someType', hidden: false, - namespaceAgnostic: false, + namespaceType: 'single' as 'single', mappings: { properties: {} }, }); }).toThrowErrorMatchingInlineSnapshot( diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 62027928c0bb5d..ed4ffef5729ab2 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -124,7 +124,7 @@ export interface SavedObjectsServiceSetup { * export const myType: SavedObjectsType = { * name: 'MyType', * hidden: false, - * namespaceAgnostic: true, + * namespaceType: 'multiple', * mappings: { * properties: { * textField: { diff --git a/src/core/server/saved_objects/saved_objects_type_registry.mock.ts b/src/core/server/saved_objects/saved_objects_type_registry.mock.ts index 8c8458d7a5ce43..8bb66859feca27 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.mock.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.mock.ts @@ -27,6 +27,8 @@ const createRegistryMock = (): jest.Mocked type === 'global'); + mock.isSingleNamespace.mockImplementation( + (type: string) => type !== 'global' && type !== 'shared' + ); + mock.isMultiNamespace.mockImplementation((type: string) => type === 'shared'); mock.isImportableAndExportable.mockReturnValue(true); return mock; diff --git a/src/core/server/saved_objects/saved_objects_type_registry.test.ts b/src/core/server/saved_objects/saved_objects_type_registry.test.ts index 4d1d5c1eacc25a..84337474f3ee32 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.test.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.test.ts @@ -23,7 +23,7 @@ import { SavedObjectsType } from './types'; const createType = (type: Partial): SavedObjectsType => ({ name: 'unknown', hidden: false, - namespaceAgnostic: false, + namespaceType: 'single' as 'single', mappings: { properties: {} }, migrations: {}, ...type, @@ -164,18 +164,92 @@ describe('SavedObjectTypeRegistry', () => { }); describe('#isNamespaceAgnostic', () => { - it('returns correct value for the type', () => { - registry.registerType(createType({ name: 'typeA', namespaceAgnostic: true })); - registry.registerType(createType({ name: 'typeB', namespaceAgnostic: false })); + const expectResult = (expected: boolean, schemaDefinition?: Partial) => { + registry = new SavedObjectTypeRegistry(); + registry.registerType(createType({ name: 'foo', ...schemaDefinition })); + expect(registry.isNamespaceAgnostic('foo')).toBe(expected); + }; - expect(registry.isNamespaceAgnostic('typeA')).toEqual(true); - expect(registry.isNamespaceAgnostic('typeB')).toEqual(false); + it(`returns false when the type is not registered`, () => { + expect(registry.isNamespaceAgnostic('unknownType')).toEqual(false); }); - it('returns false when the type is not registered', () => { - registry.registerType(createType({ name: 'typeA', namespaceAgnostic: true })); - registry.registerType(createType({ name: 'typeB', namespaceAgnostic: false })); - expect(registry.isNamespaceAgnostic('unknownType')).toEqual(false); + it(`returns true for namespaceType 'agnostic'`, () => { + expectResult(true, { namespaceType: 'agnostic' }); + }); + + it(`returns false for other namespaceType`, () => { + expectResult(false, { namespaceType: 'multiple' }); + expectResult(false, { namespaceType: 'single' }); + expectResult(false, { namespaceType: undefined }); + }); + + // deprecated test cases + it(`returns true when namespaceAgnostic is true`, () => { + expectResult(true, { namespaceAgnostic: true, namespaceType: 'agnostic' }); + expectResult(true, { namespaceAgnostic: true, namespaceType: 'multiple' }); + expectResult(true, { namespaceAgnostic: true, namespaceType: 'single' }); + expectResult(true, { namespaceAgnostic: true, namespaceType: undefined }); + }); + }); + + describe('#isSingleNamespace', () => { + const expectResult = (expected: boolean, schemaDefinition?: Partial) => { + registry = new SavedObjectTypeRegistry(); + registry.registerType(createType({ name: 'foo', ...schemaDefinition })); + expect(registry.isSingleNamespace('foo')).toBe(expected); + }; + + it(`returns true when the type is not registered`, () => { + expect(registry.isSingleNamespace('unknownType')).toEqual(true); + }); + + it(`returns true for namespaceType 'single'`, () => { + expectResult(true, { namespaceType: 'single' }); + expectResult(true, { namespaceType: undefined }); + }); + + it(`returns false for other namespaceType`, () => { + expectResult(false, { namespaceType: 'agnostic' }); + expectResult(false, { namespaceType: 'multiple' }); + }); + + // deprecated test cases + it(`returns false when namespaceAgnostic is true`, () => { + expectResult(false, { namespaceAgnostic: true, namespaceType: 'agnostic' }); + expectResult(false, { namespaceAgnostic: true, namespaceType: 'multiple' }); + expectResult(false, { namespaceAgnostic: true, namespaceType: 'single' }); + expectResult(false, { namespaceAgnostic: true, namespaceType: undefined }); + }); + }); + + describe('#isMultiNamespace', () => { + const expectResult = (expected: boolean, schemaDefinition?: Partial) => { + registry = new SavedObjectTypeRegistry(); + registry.registerType(createType({ name: 'foo', ...schemaDefinition })); + expect(registry.isMultiNamespace('foo')).toBe(expected); + }; + + it(`returns false when the type is not registered`, () => { + expect(registry.isMultiNamespace('unknownType')).toEqual(false); + }); + + it(`returns true for namespaceType 'multiple'`, () => { + expectResult(true, { namespaceType: 'multiple' }); + }); + + it(`returns false for other namespaceType`, () => { + expectResult(false, { namespaceType: 'agnostic' }); + expectResult(false, { namespaceType: 'single' }); + expectResult(false, { namespaceType: undefined }); + }); + + // deprecated test cases + it(`returns false when namespaceAgnostic is true`, () => { + expectResult(false, { namespaceAgnostic: true, namespaceType: 'agnostic' }); + expectResult(false, { namespaceAgnostic: true, namespaceType: 'multiple' }); + expectResult(false, { namespaceAgnostic: true, namespaceType: 'single' }); + expectResult(false, { namespaceAgnostic: true, namespaceType: undefined }); }); }); @@ -206,8 +280,8 @@ describe('SavedObjectTypeRegistry', () => { expect(registry.getIndex('typeWithNoIndex')).toBeUndefined(); }); it('returns undefined when the type is not registered', () => { - registry.registerType(createType({ name: 'typeA', namespaceAgnostic: true })); - registry.registerType(createType({ name: 'typeB', namespaceAgnostic: false })); + registry.registerType(createType({ name: 'typeA', namespaceType: 'agnostic' })); + registry.registerType(createType({ name: 'typeB', namespaceType: 'single' })); expect(registry.getIndex('unknownType')).toBeUndefined(); }); diff --git a/src/core/server/saved_objects/saved_objects_type_registry.ts b/src/core/server/saved_objects/saved_objects_type_registry.ts index 5580ce3815d0da..be3fdb86a994cf 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.ts @@ -25,16 +25,7 @@ import { SavedObjectsType } from './types'; * * @public */ -export type ISavedObjectTypeRegistry = Pick< - SavedObjectTypeRegistry, - | 'getType' - | 'getAllTypes' - | 'getIndex' - | 'isNamespaceAgnostic' - | 'isHidden' - | 'getImportableAndExportableTypes' - | 'isImportableAndExportable' ->; +export type ISavedObjectTypeRegistry = Omit; /** * Registry holding information about all the registered {@link SavedObjectsType | saved object types}. @@ -77,11 +68,31 @@ export class SavedObjectTypeRegistry { } /** - * Returns the `namespaceAgnostic` property for given type, or `false` if - * the type is not registered. + * Returns whether the type is namespace-agnostic (global); + * resolves to `false` if the type is not registered */ public isNamespaceAgnostic(type: string) { - return this.types.get(type)?.namespaceAgnostic ?? false; + return ( + this.types.get(type)?.namespaceType === 'agnostic' || + this.types.get(type)?.namespaceAgnostic || + false + ); + } + + /** + * Returns whether the type is single-namespace (isolated); + * resolves to `true` if the type is not registered + */ + public isSingleNamespace(type: string) { + return !this.isNamespaceAgnostic(type) && !this.isMultiNamespace(type); + } + + /** + * Returns whether the type is multi-namespace (shareable); + * resolves to `false` if the type is not registered + */ + public isMultiNamespace(type: string) { + return !this.isNamespaceAgnostic(type) && this.types.get(type)?.namespaceType === 'multiple'; } /** diff --git a/src/core/server/saved_objects/schema/schema.test.ts b/src/core/server/saved_objects/schema/schema.test.ts index 43cf27fbae7907..f2daa13e43fce5 100644 --- a/src/core/server/saved_objects/schema/schema.test.ts +++ b/src/core/server/saved_objects/schema/schema.test.ts @@ -17,32 +17,90 @@ * under the License. */ -import { SavedObjectsSchema } from './schema'; +import { SavedObjectsSchema, SavedObjectsSchemaDefinition } from './schema'; describe('#isNamespaceAgnostic', () => { + const expectResult = (expected: boolean, schemaDefinition?: SavedObjectsSchemaDefinition) => { + const schema = new SavedObjectsSchema(schemaDefinition); + const result = schema.isNamespaceAgnostic('foo'); + expect(result).toBe(expected); + }; + + it(`returns false when no schema is defined`, () => { + expectResult(false); + }); + it(`returns false for unknown types`, () => { - const schema = new SavedObjectsSchema(); - const result = schema.isNamespaceAgnostic('bar'); - expect(result).toBe(false); + expectResult(false, { bar: {} }); }); - it(`returns true for explicitly namespace agnostic type`, () => { - const schema = new SavedObjectsSchema({ - foo: { - isNamespaceAgnostic: true, - }, - }); - const result = schema.isNamespaceAgnostic('foo'); - expect(result).toBe(true); + it(`returns false for non-namespace-agnostic type`, () => { + expectResult(false, { foo: { isNamespaceAgnostic: false } }); + expectResult(false, { foo: { isNamespaceAgnostic: undefined } }); }); - it(`returns false for explicitly namespaced type`, () => { - const schema = new SavedObjectsSchema({ - foo: { - isNamespaceAgnostic: false, - }, - }); - const result = schema.isNamespaceAgnostic('foo'); - expect(result).toBe(false); + it(`returns true for explicitly namespace-agnostic type`, () => { + expectResult(true, { foo: { isNamespaceAgnostic: true } }); + }); +}); + +describe('#isSingleNamespace', () => { + const expectResult = (expected: boolean, schemaDefinition?: SavedObjectsSchemaDefinition) => { + const schema = new SavedObjectsSchema(schemaDefinition); + const result = schema.isSingleNamespace('foo'); + expect(result).toBe(expected); + }; + + it(`returns true when no schema is defined`, () => { + expectResult(true); + }); + + it(`returns true for unknown types`, () => { + expectResult(true, { bar: {} }); + }); + + it(`returns false for explicitly namespace-agnostic type`, () => { + expectResult(false, { foo: { isNamespaceAgnostic: true } }); + }); + + it(`returns false for explicitly multi-namespace type`, () => { + expectResult(false, { foo: { multiNamespace: true } }); + }); + + it(`returns true for non-namespace-agnostic and non-multi-namespace type`, () => { + expectResult(true, { foo: { isNamespaceAgnostic: false, multiNamespace: false } }); + expectResult(true, { foo: { isNamespaceAgnostic: false, multiNamespace: undefined } }); + expectResult(true, { foo: { isNamespaceAgnostic: undefined, multiNamespace: false } }); + expectResult(true, { foo: { isNamespaceAgnostic: undefined, multiNamespace: undefined } }); + }); +}); + +describe('#isMultiNamespace', () => { + const expectResult = (expected: boolean, schemaDefinition?: SavedObjectsSchemaDefinition) => { + const schema = new SavedObjectsSchema(schemaDefinition); + const result = schema.isMultiNamespace('foo'); + expect(result).toBe(expected); + }; + + it(`returns false when no schema is defined`, () => { + expectResult(false); + }); + + it(`returns false for unknown types`, () => { + expectResult(false, { bar: {} }); + }); + + it(`returns false for explicitly namespace-agnostic type`, () => { + expectResult(false, { foo: { isNamespaceAgnostic: true } }); + }); + + it(`returns false for non-multi-namespace type`, () => { + expectResult(false, { foo: { multiNamespace: false } }); + expectResult(false, { foo: { multiNamespace: undefined } }); + }); + + it(`returns true for non-namespace-agnostic and explicitly multi-namespace type`, () => { + expectResult(true, { foo: { isNamespaceAgnostic: false, multiNamespace: true } }); + expectResult(true, { foo: { isNamespaceAgnostic: undefined, multiNamespace: true } }); }); }); diff --git a/src/core/server/saved_objects/schema/schema.ts b/src/core/server/saved_objects/schema/schema.ts index 17ca406ea109a9..ba1905158e8229 100644 --- a/src/core/server/saved_objects/schema/schema.ts +++ b/src/core/server/saved_objects/schema/schema.ts @@ -24,7 +24,8 @@ import { LegacyConfig } from '../../legacy'; * @internal **/ interface SavedObjectsSchemaTypeDefinition { - isNamespaceAgnostic: boolean; + isNamespaceAgnostic?: boolean; + multiNamespace?: boolean; hidden?: boolean; indexPattern?: ((config: LegacyConfig) => string) | string; convertToAliasScript?: string; @@ -72,7 +73,7 @@ export class SavedObjectsSchema { } public isNamespaceAgnostic(type: string) { - // if no plugins have registered a uiExports.savedObjectSchemas, + // if no plugins have registered a Saved Objects Schema, // this.schema will be undefined, and no types are namespace agnostic if (!this.definition) { return false; @@ -84,4 +85,32 @@ export class SavedObjectsSchema { } return Boolean(typeSchema.isNamespaceAgnostic); } + + public isSingleNamespace(type: string) { + // if no plugins have registered a Saved Objects Schema, + // this.schema will be undefined, and all types are namespace isolated + if (!this.definition) { + return true; + } + + const typeSchema = this.definition[type]; + if (!typeSchema) { + return true; + } + return !Boolean(typeSchema.isNamespaceAgnostic) && !Boolean(typeSchema.multiNamespace); + } + + public isMultiNamespace(type: string) { + // if no plugins have registered a Saved Objects Schema, + // this.schema will be undefined, and no types are multi-namespace + if (!this.definition) { + return false; + } + + const typeSchema = this.definition[type]; + if (!typeSchema) { + return false; + } + return !Boolean(typeSchema.isNamespaceAgnostic) && Boolean(typeSchema.multiNamespace); + } } diff --git a/src/core/server/saved_objects/serialization/serializer.test.ts b/src/core/server/saved_objects/serialization/serializer.test.ts index 8f09b25bb39088..1a7dfdd2d130e7 100644 --- a/src/core/server/saved_objects/serialization/serializer.test.ts +++ b/src/core/server/saved_objects/serialization/serializer.test.ts @@ -19,101 +19,101 @@ import _ from 'lodash'; import { SavedObjectsSerializer } from './serializer'; +import { SavedObjectsRawDoc } from './types'; import { typeRegistryMock } from '../saved_objects_type_registry.mock'; import { encodeVersion } from '../version'; -describe('saved object conversion', () => { - let typeRegistry: ReturnType; - - beforeEach(() => { - typeRegistry = typeRegistryMock.create(); - typeRegistry.isNamespaceAgnostic.mockReturnValue(false); +let typeRegistry = typeRegistryMock.create(); +typeRegistry.isNamespaceAgnostic.mockReturnValue(true); +typeRegistry.isSingleNamespace.mockReturnValue(false); +typeRegistry.isMultiNamespace.mockReturnValue(false); +const namespaceAgnosticSerializer = new SavedObjectsSerializer(typeRegistry); + +typeRegistry = typeRegistryMock.create(); +typeRegistry.isNamespaceAgnostic.mockReturnValue(false); +typeRegistry.isSingleNamespace.mockReturnValue(true); +typeRegistry.isMultiNamespace.mockReturnValue(false); +const singleNamespaceSerializer = new SavedObjectsSerializer(typeRegistry); + +typeRegistry = typeRegistryMock.create(); +typeRegistry.isNamespaceAgnostic.mockReturnValue(false); +typeRegistry.isSingleNamespace.mockReturnValue(false); +typeRegistry.isMultiNamespace.mockReturnValue(true); +const multiNamespaceSerializer = new SavedObjectsSerializer(typeRegistry); + +const sampleTemplate = { + _id: 'foo:bar', + _source: { + type: 'foo', + }, +}; +const createSampleDoc = (raw: any, template = sampleTemplate): SavedObjectsRawDoc => + _.defaultsDeep(raw, template); + +describe('#rawToSavedObject', () => { + test('it copies the _source.type property to type', () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, + }); + expect(actual).toHaveProperty('type', 'foo'); }); - describe('#rawToSavedObject', () => { - test('it copies the _source.type property to type', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - }, - }); - expect(actual).toHaveProperty('type', 'foo'); + test('it copies the _source.references property to references', () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + references: [{ name: 'ref_0', type: 'index-pattern', id: 'pattern*' }], + }, }); + expect(actual).toHaveProperty('references', [ + { + name: 'ref_0', + type: 'index-pattern', + id: 'pattern*', + }, + ]); + }); - test('it copies the _source.references property to references', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - references: [{ name: 'ref_0', type: 'index-pattern', id: 'pattern*' }], - }, - }); - expect(actual).toHaveProperty('references', [ - { - name: 'ref_0', - type: 'index-pattern', - id: 'pattern*', + test('if specified it copies the _source.migrationVersion property to migrationVersion', () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + migrationVersion: { + hello: '1.2.3', + acl: '33.3.5', }, - ]); + }, }); - - test('if specified it copies the _source.migrationVersion property to migrationVersion', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - migrationVersion: { - hello: '1.2.3', - acl: '33.3.5', - }, - }, - }); - expect(actual).toHaveProperty('migrationVersion', { - hello: '1.2.3', - acl: '33.3.5', - }); + expect(actual).toHaveProperty('migrationVersion', { + hello: '1.2.3', + acl: '33.3.5', }); + }); - test(`if _source.migrationVersion is unspecified it doesn't set migrationVersion`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - }, - }); - expect(actual).not.toHaveProperty('migrationVersion'); + test(`if _source.migrationVersion is unspecified it doesn't set migrationVersion`, () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, }); + expect(actual).not.toHaveProperty('migrationVersion'); + }); - test('it converts the id and type properties, and retains migrationVersion', () => { - const now = String(new Date()); - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'hello:world', - _seq_no: 3, - _primary_term: 1, - _source: { - type: 'hello', - hello: { - a: 'b', - c: 'd', - }, - migrationVersion: { - hello: '1.2.3', - acl: '33.3.5', - }, - updated_at: now, - }, - }); - const expected = { - id: 'world', + test('it converts the id and type properties, and retains migrationVersion', () => { + const now = String(new Date()); + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'hello:world', + _seq_no: 3, + _primary_term: 1, + _source: { type: 'hello', - version: encodeVersion(3, 1), - attributes: { + hello: { a: 'b', c: 'd', }, @@ -122,909 +122,937 @@ describe('saved object conversion', () => { acl: '33.3.5', }, updated_at: now, - references: [], - }; - expect(expected).toEqual(actual); + }, + }); + const expected = { + id: 'world', + type: 'hello', + version: encodeVersion(3, 1), + attributes: { + a: 'b', + c: 'd', + }, + migrationVersion: { + hello: '1.2.3', + acl: '33.3.5', + }, + updated_at: now, + references: [], + }; + expect(expected).toEqual(actual); + }); + + test(`if version is unspecified it doesn't set version`, () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + hello: {}, + }, }); + expect(actual).not.toHaveProperty('version'); + }); - test(`if version is unspecified it doesn't set version`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ + test(`if specified it encodes _seq_no and _primary_term to version`, () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _seq_no: 4, + _primary_term: 1, + _source: { + type: 'foo', + hello: {}, + }, + }); + expect(actual).toHaveProperty('version', encodeVersion(4, 1)); + }); + + test(`if only _seq_no is specified it throws`, () => { + expect(() => + singleNamespaceSerializer.rawToSavedObject({ _id: 'foo:bar', + _seq_no: 4, _source: { type: 'foo', hello: {}, }, - }); - expect(actual).not.toHaveProperty('version'); - }); + }) + ).toThrowErrorMatchingInlineSnapshot(`"_primary_term from elasticsearch must be an integer"`); + }); - test(`if specified it encodes _seq_no and _primary_term to version`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ + test(`if only _primary_term is throws`, () => { + expect(() => + singleNamespaceSerializer.rawToSavedObject({ _id: 'foo:bar', - _seq_no: 4, _primary_term: 1, _source: { type: 'foo', hello: {}, }, - }); - expect(actual).toHaveProperty('version', encodeVersion(4, 1)); - }); + }) + ).toThrowErrorMatchingInlineSnapshot(`"_seq_no from elasticsearch must be an integer"`); + }); - test(`if only _seq_no is specified it throws`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect(() => - serializer.rawToSavedObject({ - _id: 'foo:bar', - _seq_no: 4, - _source: { - type: 'foo', - hello: {}, - }, - }) - ).toThrowErrorMatchingInlineSnapshot(`"_primary_term from elasticsearch must be an integer"`); + test('if specified it copies the _source.updated_at property to updated_at', () => { + const now = Date(); + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + updated_at: now, + }, }); + expect(actual).toHaveProperty('updated_at', now); + }); - test(`if only _primary_term is throws`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect(() => - serializer.rawToSavedObject({ - _id: 'foo:bar', - _primary_term: 1, - _source: { - type: 'foo', - hello: {}, - }, - }) - ).toThrowErrorMatchingInlineSnapshot(`"_seq_no from elasticsearch must be an integer"`); + test(`if _source.updated_at is unspecified it doesn't set updated_at`, () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, }); + expect(actual).not.toHaveProperty('updated_at'); + }); - test('if specified it copies the _source.updated_at property to updated_at', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const now = Date(); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - updated_at: now, + test('it does not pass unknown properties through', () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'universe', + _source: { + type: 'hello', + hello: { + world: 'earth', }, - }); - expect(actual).toHaveProperty('updated_at', now); + banjo: 'Steve Martin', + }, + }); + expect(actual).toEqual({ + id: 'universe', + type: 'hello', + attributes: { + world: 'earth', + }, + references: [], }); + }); - test(`if _source.updated_at is unspecified it doesn't set updated_at`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - }, - }); - expect(actual).not.toHaveProperty('updated_at'); + test('it does not create attributes if [type] is missing', () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'universe', + _source: { + type: 'hello', + }, }); + expect(actual).toEqual({ + id: 'universe', + type: 'hello', + references: [], + }); + }); - test('it does not pass unknown properties through', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ + test('it fails for documents which do not specify a type', () => { + expect(() => + singleNamespaceSerializer.rawToSavedObject({ _id: 'universe', _source: { - type: 'hello', hello: { world: 'earth', }, - banjo: 'Steve Martin', + } as any, + }) + ).toThrow(/Expected "undefined" to be a saved object type/); + }); + + test('it is complimentary with savedObjectToRaw', () => { + const raw = { + _id: 'foo-namespace:foo:bar', + _primary_term: 24, + _seq_no: 42, + _source: { + type: 'foo', + foo: { + meaning: 42, + nested: { stuff: 'here' }, }, - }); - expect(actual).toEqual({ - id: 'universe', - type: 'hello', - attributes: { - world: 'earth', + migrationVersion: { + foo: '1.2.3', + bar: '9.8.7', }, + namespace: 'foo-namespace', + updated_at: String(new Date()), references: [], - }); - }); + }, + }; + + expect( + singleNamespaceSerializer.savedObjectToRaw( + singleNamespaceSerializer.rawToSavedObject(_.cloneDeep(raw)) + ) + ).toEqual(raw); + }); - test('it does not create attributes if [type] is missing', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'universe', - _source: { - type: 'hello', - }, - }); - expect(actual).toEqual({ - id: 'universe', + test('it handles unprefixed ids', () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'universe', + _source: { type: 'hello', - references: [], - }); + }, }); - test('it fails for documents which do not specify a type', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect(() => - serializer.rawToSavedObject({ - _id: 'universe', - _source: { - hello: { - world: 'earth', - }, - } as any, - }) - ).toThrow(/Expected "undefined" to be a saved object type/); + expect(actual).toHaveProperty('id', 'universe'); + }); + + describe('namespace-agnostic type with a namespace', () => { + const raw = createSampleDoc({ _source: { namespace: 'baz' } }); + const actual = namespaceAgnosticSerializer.rawToSavedObject(raw); + + test(`removes type prefix from _id`, () => { + expect(actual).toHaveProperty('id', 'bar'); }); - test('it is complimentary with savedObjectToRaw', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const raw = { - _id: 'foo-namespace:foo:bar', - _primary_term: 24, - _seq_no: 42, - _source: { - type: 'foo', - foo: { - meaning: 42, - nested: { stuff: 'here' }, - }, - migrationVersion: { - foo: '1.2.3', - bar: '9.8.7', - }, - namespace: 'foo-namespace', - updated_at: String(new Date()), - references: [], - }, - }; + test(`copies _id to id if prefixed by namespace and type`, () => { + const _id = `${raw._source.namespace}:${raw._id}`; + const _actual = namespaceAgnosticSerializer.rawToSavedObject({ ...raw, _id }); + expect(_actual).toHaveProperty('id', _id); + }); - expect(serializer.savedObjectToRaw(serializer.rawToSavedObject(_.cloneDeep(raw)))).toEqual( - raw - ); + test(`doesn't copy _source.namespace to namespace`, () => { + expect(actual).not.toHaveProperty('namespace'); }); + }); - test('it handles unprefixed ids', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'universe', - _source: { - type: 'hello', - }, - }); + describe('namespace-agnostic type with namespaces', () => { + const raw = createSampleDoc({ _source: { namespaces: ['baz'] } }); + const actual = namespaceAgnosticSerializer.rawToSavedObject(raw); - expect(actual).toHaveProperty('id', 'universe'); + test(`doesn't copy _source.namespaces to namespaces`, () => { + expect(actual).not.toHaveProperty('namespaces'); }); + }); - describe('namespaced type without a namespace', () => { - test(`removes type prefix from _id`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - }, - }); + describe('single-namespace type without a namespace', () => { + const raw = createSampleDoc({}); + const actual = singleNamespaceSerializer.rawToSavedObject(raw); - expect(actual).toHaveProperty('id', 'bar'); - }); + test(`removes type prefix from _id`, () => { + expect(actual).toHaveProperty('id', 'bar'); + }); - test(`if prefixed by random prefix and type it copies _id to id`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'random:foo:bar', - _source: { - type: 'foo', - }, - }); + test(`copies _id to id if prefixed by random prefix and type`, () => { + const _id = `random:${raw._id}`; + const _actual = singleNamespaceSerializer.rawToSavedObject({ ...raw, _id }); + expect(_actual).toHaveProperty('id', _id); + }); - expect(actual).toHaveProperty('id', 'random:foo:bar'); - }); + test(`doesn't specify namespace`, () => { + expect(actual).not.toHaveProperty('namespace'); + }); + }); - test(`doesn't specify namespace`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - }, - }); + describe('single-namespace type with a namespace', () => { + const namespace = 'baz'; + const raw = createSampleDoc({ _source: { namespace } }); + const actual = singleNamespaceSerializer.rawToSavedObject(raw); - expect(actual).not.toHaveProperty('namespace'); - }); + test(`removes type and namespace prefix from _id`, () => { + const _id = `${namespace}:${raw._id}`; + const _actual = singleNamespaceSerializer.rawToSavedObject({ ...raw, _id }); + expect(_actual).toHaveProperty('id', 'bar'); }); - describe('namespaced type with a namespace', () => { - test(`removes type and namespace prefix from _id`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'baz:foo:bar', - _source: { - type: 'foo', - namespace: 'baz', - }, - }); + test(`copies _id to id if prefixed only by type`, () => { + expect(actual).toHaveProperty('id', raw._id); + }); - expect(actual).toHaveProperty('id', 'bar'); - }); + test(`copies _id to id if prefixed by random prefix and type`, () => { + const _id = `random:${raw._id}`; + const _actual = singleNamespaceSerializer.rawToSavedObject({ ...raw, _id }); + expect(_actual).toHaveProperty('id', _id); + }); - test(`if prefixed by only type it copies _id to id`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - namespace: 'baz', - }, - }); + test(`copies _source.namespace to namespace`, () => { + expect(actual).toHaveProperty('namespace', 'baz'); + }); + }); - expect(actual).toHaveProperty('id', 'foo:bar'); - }); + describe('single-namespace type with namespaces', () => { + const raw = createSampleDoc({ _source: { namespaces: ['baz'] } }); + const actual = singleNamespaceSerializer.rawToSavedObject(raw); - test(`if prefixed by random prefix and type it copies _id to id`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'random:foo:bar', - _source: { - type: 'foo', - namespace: 'baz', - }, - }); + test(`doesn't copy _source.namespaces to namespaces`, () => { + expect(actual).not.toHaveProperty('namespaces'); + }); + }); - expect(actual).toHaveProperty('id', 'random:foo:bar'); - }); + describe('multi-namespace type with a namespace', () => { + const raw = createSampleDoc({ _source: { namespace: 'baz' } }); + const actual = multiNamespaceSerializer.rawToSavedObject(raw); - test(`copies _source.namespace to namespace`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'baz:foo:bar', - _source: { - type: 'foo', - namespace: 'baz', - }, - }); + test(`removes type prefix from _id`, () => { + expect(actual).toHaveProperty('id', 'bar'); + }); - expect(actual).toHaveProperty('namespace', 'baz'); - }); + test(`copies _id to id if prefixed by namespace and type`, () => { + const _id = `${raw._source.namespace}:${raw._id}`; + const _actual = multiNamespaceSerializer.rawToSavedObject({ ...raw, _id }); + expect(_actual).toHaveProperty('id', _id); }); - describe('namespace agnostic type with a namespace', () => { - beforeEach(() => { - typeRegistry.isNamespaceAgnostic.mockReturnValue(true); - }); + test(`doesn't copy _source.namespace to namespace`, () => { + expect(actual).not.toHaveProperty('namespace'); + }); + }); - test(`removes type prefix from _id`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'foo:bar', - _source: { - type: 'foo', - namespace: 'baz', - }, - }); + describe('multi-namespace type with namespaces', () => { + const raw = createSampleDoc({ _source: { namespaces: ['baz'] } }); + const actual = multiNamespaceSerializer.rawToSavedObject(raw); - expect(actual).toHaveProperty('id', 'bar'); - }); + test(`copies _source.namespaces to namespaces`, () => { + expect(actual).toHaveProperty('namespaces', ['baz']); + }); + }); +}); - test(`if prefixed by namespace and type it copies _id to id`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'baz:foo:bar', - _source: { - type: 'foo', - namespace: 'baz', - }, - }); +describe('#savedObjectToRaw', () => { + test('it copies the type property to _source.type and uses the ROOT_TYPE as _type', () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + attributes: {}, + } as any); - expect(actual).toHaveProperty('id', 'baz:foo:bar'); - }); + expect(actual._source).toHaveProperty('type', 'foo'); + }); - test(`doesn't copy _source.namespace to namespace`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.rawToSavedObject({ - _id: 'baz:foo:bar', - _source: { - type: 'foo', - namespace: 'baz', - }, - }); + test('it copies the references property to _source.references', () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + id: '1', + type: 'foo', + attributes: {}, + references: [{ name: 'ref_0', type: 'index-pattern', id: 'pattern*' }], + }); + expect(actual._source).toHaveProperty('references', [ + { + name: 'ref_0', + type: 'index-pattern', + id: 'pattern*', + }, + ]); + }); + + test('if specified it copies the updated_at property to _source.updated_at', () => { + const now = new Date(); + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + updated_at: now, + } as any); + + expect(actual._source).toHaveProperty('updated_at', now); + }); + + test(`if unspecified it doesn't add updated_at property to _source`, () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + } as any); - expect(actual).not.toHaveProperty('namespace'); - }); + expect(actual._source).not.toHaveProperty('updated_at'); + }); + + test('it copies the migrationVersion property to _source.migrationVersion', () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + migrationVersion: { + foo: '1.2.3', + bar: '9.8.7', + }, + } as any); + + expect(actual._source).toHaveProperty('migrationVersion', { + foo: '1.2.3', + bar: '9.8.7', }); }); - describe('#savedObjectToRaw', () => { - test('it copies the type property to _source.type and uses the ROOT_TYPE as _type', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - type: 'foo', + test(`if unspecified it doesn't add migrationVersion property to _source`, () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + } as any); + + expect(actual._source).not.toHaveProperty('migrationVersion'); + }); + + test('it decodes the version property to _seq_no and _primary_term', () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + version: encodeVersion(1, 2), + } as any); + + expect(actual).toHaveProperty('_seq_no', 1); + expect(actual).toHaveProperty('_primary_term', 2); + }); + + test(`if unspecified it doesn't add _seq_no or _primary_term properties`, () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: '', + attributes: {}, + } as any); + + expect(actual).not.toHaveProperty('_seq_no'); + expect(actual).not.toHaveProperty('_primary_term'); + }); + + test(`if version invalid it throws`, () => { + expect(() => + singleNamespaceSerializer.savedObjectToRaw({ + type: '', attributes: {}, - } as any); + version: 'foo', + } as any) + ).toThrowErrorMatchingInlineSnapshot(`"Invalid version [foo]"`); + }); - expect(actual._source).toHaveProperty('type', 'foo'); + test('it copies attributes to _source[type]', () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + attributes: { + foo: true, + bar: 'quz', + }, + } as any); + + expect(actual._source).toHaveProperty('foo', { + foo: true, + bar: 'quz', }); + }); - test('it copies the references property to _source.references', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - id: '1', + describe('single-namespace type without a namespace', () => { + test('generates an id prefixed with type, if no id is specified', () => { + const v1 = singleNamespaceSerializer.savedObjectToRaw({ type: 'foo', - attributes: {}, - references: [{ name: 'ref_0', type: 'index-pattern', id: 'pattern*' }], - }); - expect(actual._source).toHaveProperty('references', [ - { - name: 'ref_0', - type: 'index-pattern', - id: 'pattern*', - }, - ]); + attributes: { bar: true }, + } as any); + + const v2 = singleNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + attributes: { bar: true }, + } as any); + + expect(v1._id).toMatch(/^foo\:[\w-]+$/); + expect(v1._id).not.toEqual(v2._id); }); - test('if specified it copies the updated_at property to _source.updated_at', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const now = new Date(); - const actual = serializer.savedObjectToRaw({ + test(`doesn't specify _source.namespace`, () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ type: '', attributes: {}, - updated_at: now, } as any); - expect(actual._source).toHaveProperty('updated_at', now); + expect(actual._source).not.toHaveProperty('namespace'); }); + }); - test(`if unspecified it doesn't add updated_at property to _source`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - type: '', + describe('single-namespace type with a namespace', () => { + test('generates an id prefixed with namespace and type, if no id is specified', () => { + const v1 = singleNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', + attributes: { bar: true }, + } as any); + + const v2 = singleNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', + attributes: { bar: true }, + } as any); + + expect(v1._id).toMatch(/^bar\:foo\:[\w-]+$/); + expect(v1._id).not.toEqual(v2._id); + }); + + test(`it copies namespace to _source.namespace`, () => { + const actual = singleNamespaceSerializer.savedObjectToRaw({ + type: 'foo', attributes: {}, + namespace: 'bar', } as any); - expect(actual._source).not.toHaveProperty('updated_at'); + expect(actual._source).toHaveProperty('namespace', 'bar'); }); + }); - test('it copies the migrationVersion property to _source.migrationVersion', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - type: '', + describe('single-namespace type with namespaces', () => { + test('generates an id prefixed with type, if no id is specified', () => { + const v1 = namespaceAgnosticSerializer.savedObjectToRaw({ + type: 'foo', + namespaces: ['bar'], + attributes: { bar: true }, + } as any); + + const v2 = namespaceAgnosticSerializer.savedObjectToRaw({ + type: 'foo', + namespaces: ['bar'], + attributes: { bar: true }, + } as any); + + expect(v1._id).toMatch(/^foo\:[\w-]+$/); + expect(v1._id).not.toEqual(v2._id); + }); + + test(`doesn't specify _source.namespaces`, () => { + const actual = namespaceAgnosticSerializer.savedObjectToRaw({ + type: 'foo', + namespaces: ['bar'], attributes: {}, - migrationVersion: { - foo: '1.2.3', - bar: '9.8.7', - }, } as any); - expect(actual._source).toHaveProperty('migrationVersion', { - foo: '1.2.3', - bar: '9.8.7', - }); + expect(actual._source).not.toHaveProperty('namespaces'); }); + }); - test(`if unspecified it doesn't add migrationVersion property to _source`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - type: '', + describe('namespace-agnostic type with a namespace', () => { + test('generates an id prefixed with type, if no id is specified', () => { + const v1 = namespaceAgnosticSerializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', + attributes: { bar: true }, + } as any); + + const v2 = namespaceAgnosticSerializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', + attributes: { bar: true }, + } as any); + + expect(v1._id).toMatch(/^foo\:[\w-]+$/); + expect(v1._id).not.toEqual(v2._id); + }); + + test(`doesn't specify _source.namespace`, () => { + const actual = namespaceAgnosticSerializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', attributes: {}, } as any); - expect(actual._source).not.toHaveProperty('migrationVersion'); + expect(actual._source).not.toHaveProperty('namespace'); }); + }); - test('it decodes the version property to _seq_no and _primary_term', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - type: '', + describe('namespace-agnostic type with namespaces', () => { + test('generates an id prefixed with type, if no id is specified', () => { + const v1 = namespaceAgnosticSerializer.savedObjectToRaw({ + type: 'foo', + namespaces: ['bar'], + attributes: { bar: true }, + } as any); + + const v2 = namespaceAgnosticSerializer.savedObjectToRaw({ + type: 'foo', + namespaces: ['bar'], + attributes: { bar: true }, + } as any); + + expect(v1._id).toMatch(/^foo\:[\w-]+$/); + expect(v1._id).not.toEqual(v2._id); + }); + + test(`doesn't specify _source.namespaces`, () => { + const actual = namespaceAgnosticSerializer.savedObjectToRaw({ + type: 'foo', + namespaces: ['bar'], attributes: {}, - version: encodeVersion(1, 2), } as any); - expect(actual).toHaveProperty('_seq_no', 1); - expect(actual).toHaveProperty('_primary_term', 2); + expect(actual._source).not.toHaveProperty('namespaces'); }); + }); - test(`if unspecified it doesn't add _seq_no or _primary_term properties`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - type: '', + describe('multi-namespace type with a namespace', () => { + test('generates an id prefixed with type, if no id is specified', () => { + const v1 = multiNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', + attributes: { bar: true }, + } as any); + + const v2 = multiNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', + attributes: { bar: true }, + } as any); + + expect(v1._id).toMatch(/^foo\:[\w-]+$/); + expect(v1._id).not.toEqual(v2._id); + }); + + test(`doesn't specify _source.namespace`, () => { + const actual = multiNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + namespace: 'bar', attributes: {}, } as any); - expect(actual).not.toHaveProperty('_seq_no'); - expect(actual).not.toHaveProperty('_primary_term'); + expect(actual._source).not.toHaveProperty('namespace'); }); + }); + + describe('multi-namespace type with namespaces', () => { + test('generates an id prefixed with type, if no id is specified', () => { + const v1 = multiNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + namespaces: ['bar'], + attributes: { bar: true }, + } as any); + + const v2 = multiNamespaceSerializer.savedObjectToRaw({ + type: 'foo', + namespaces: ['bar'], + attributes: { bar: true }, + } as any); - test(`if version invalid it throws`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect(() => - serializer.savedObjectToRaw({ - type: '', - attributes: {}, - version: 'foo', - } as any) - ).toThrowErrorMatchingInlineSnapshot(`"Invalid version [foo]"`); + expect(v1._id).toMatch(/^foo\:[\w-]+$/); + expect(v1._id).not.toEqual(v2._id); }); - test('it copies attributes to _source[type]', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ + test(`it copies namespaces to _source.namespaces`, () => { + const actual = multiNamespaceSerializer.savedObjectToRaw({ type: 'foo', - attributes: { - foo: true, - bar: 'quz', - }, + namespaces: ['bar'], + attributes: {}, } as any); - expect(actual._source).toHaveProperty('foo', { - foo: true, - bar: 'quz', - }); + expect(actual._source).toHaveProperty('namespaces', ['bar']); }); + }); +}); - describe('namespaced type without a namespace', () => { - test('generates an id prefixed with type, if no id is specified', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const v1 = serializer.savedObjectToRaw({ - type: 'foo', - attributes: { - bar: true, +describe('#isRawSavedObject', () => { + describe('single-namespace type without a namespace', () => { + test('is true if the id is prefixed and the type matches', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + hello: {}, }, - } as any); + }) + ).toBeTruthy(); + }); - const v2 = serializer.savedObjectToRaw({ - type: 'foo', - attributes: { - bar: true, + test('is false if the id is not prefixed', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'world', + _source: { + type: 'hello', + hello: {}, }, - } as any); + }) + ).toBeFalsy(); + }); - expect(v1._id).toMatch(/foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); + test('is false if the type attribute is missing', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + hello: {}, + } as any, + }) + ).toBeFalsy(); + }); - test(`doesn't specify _source.namespace`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - type: '', - attributes: {}, - } as any); + test(`is false if the type prefix omits the :`, () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'helloworld', + _source: { + type: 'hello', + hello: {}, + }, + }) + ).toBeFalsy(); + }); - expect(actual._source).not.toHaveProperty('namespace'); - }); + test('is false if the type attribute does not match the id', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'jam', + jam: {}, + hello: {}, + }, + }) + ).toBeFalsy(); }); - describe('namespaced type with a namespace', () => { - test('generates an id prefixed with namespace and type, if no id is specified', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const v1 = serializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { - bar: true, + test('is false if there is no [type] attribute', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + jam: {}, }, - } as any); + }) + ).toBeFalsy(); + }); + }); - const v2 = serializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { - bar: true, + describe('single-namespace type with a namespace', () => { + test('is true if the id is prefixed with type and namespace and the type matches', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'foo:hello:world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', }, - } as any); + }) + ).toBeTruthy(); + }); - expect(v1._id).toMatch(/bar\:foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); + test('is false if the id is not prefixed by anything', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); - test(`it copies namespace to _source.namespace`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - type: 'foo', - attributes: {}, - namespace: 'bar', - } as any); + test('is false if the id is prefixed only with type and the type matches', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if the id is prefixed only with namespace and the namespace matches', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'foo:world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); - expect(actual._source).toHaveProperty('namespace', 'bar'); - }); + test(`is false if the id prefix omits the trailing :`, () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'foo:helloworld', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); }); - describe('namespace agnostic type with a namespace', () => { - beforeEach(() => { - typeRegistry.isNamespaceAgnostic.mockReturnValue(true); - }); + test('is false if the type attribute is missing', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'foo:hello:world', + _source: { + hello: {}, + namespace: 'foo', + } as any, + }) + ).toBeFalsy(); + }); - test('generates an id prefixed with type, if no id is specified', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const v1 = serializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { - bar: true, + test('is false if the type attribute does not match the id', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'foo:hello:world', + _source: { + type: 'jam', + jam: {}, + hello: {}, + namespace: 'foo', }, - } as any); + }) + ).toBeFalsy(); + }); - const v2 = serializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { - bar: true, + test('is false if the namespace attribute does not match the id', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'bar:jam:world', + _source: { + type: 'jam', + jam: {}, + hello: {}, + namespace: 'foo', }, - } as any); + }) + ).toBeFalsy(); + }); - expect(v1._id).toMatch(/foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); + test('is false if there is no [type] attribute', () => { + expect( + singleNamespaceSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + jam: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + }); - test(`doesn't specify _source.namespace`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const actual = serializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: {}, - } as any); + describe('namespace-agnostic type with a namespace', () => { + test('is true if the id is prefixed with type and the type matches', () => { + expect( + namespaceAgnosticSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeTruthy(); + }); + + test('is false if the id is not prefixed', () => { + expect( + namespaceAgnosticSerializer.isRawSavedObject({ + _id: 'world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if the id is prefixed with type and namespace', () => { + expect( + namespaceAgnosticSerializer.isRawSavedObject({ + _id: 'foo:hello:world', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test(`is false if the type prefix omits the :`, () => { + expect( + namespaceAgnosticSerializer.isRawSavedObject({ + _id: 'helloworld', + _source: { + type: 'hello', + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if the type attribute is missing', () => { + expect( + namespaceAgnosticSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + hello: {}, + namespace: 'foo', + } as any, + }) + ).toBeFalsy(); + }); + + test('is false if the type attribute does not match the id', () => { + expect( + namespaceAgnosticSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'jam', + jam: {}, + hello: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + + test('is false if there is no [type] attribute', () => { + expect( + namespaceAgnosticSerializer.isRawSavedObject({ + _id: 'hello:world', + _source: { + type: 'hello', + jam: {}, + namespace: 'foo', + }, + }) + ).toBeFalsy(); + }); + }); +}); - expect(actual._source).not.toHaveProperty('namespace'); - }); +describe('#generateRawId', () => { + describe('single-namespace type without a namespace', () => { + test('generates an id if none is specified', () => { + const id = singleNamespaceSerializer.generateRawId('', 'goodbye'); + expect(id).toMatch(/^goodbye\:[\w-]+$/); + }); + + test('uses the id that is specified', () => { + const id = singleNamespaceSerializer.generateRawId('', 'hello', 'world'); + expect(id).toEqual('hello:world'); }); }); - describe('#isRawSavedObject', () => { - describe('namespaced type without a namespace', () => { - test('is true if the id is prefixed and the type matches', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'hello', - hello: {}, - }, - }) - ).toBeTruthy(); - }); - - test('is false if the id is not prefixed', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'world', - _source: { - type: 'hello', - hello: {}, - }, - }) - ).toBeFalsy(); - }); - - test('is false if the type attribute is missing', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - hello: {}, - } as any, - }) - ).toBeFalsy(); - }); - - test(`is false if the type prefix omits the :`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'helloworld', - _source: { - type: 'hello', - hello: {}, - }, - }) - ).toBeFalsy(); - }); - - test('is false if the type attribute does not match the id', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'jam', - jam: {}, - hello: {}, - }, - }) - ).toBeFalsy(); - }); - - test('is false if there is no [type] attribute', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'hello', - jam: {}, - }, - }) - ).toBeFalsy(); - }); - }); - - describe('namespaced type with a namespace', () => { - test('is true if the id is prefixed with type and namespace and the type matches', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'foo:hello:world', - _source: { - type: 'hello', - hello: {}, - namespace: 'foo', - }, - }) - ).toBeTruthy(); - }); - - test('is false if the id is not prefixed by anything', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'world', - _source: { - type: 'hello', - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test('is false if the id is prefixed only with type and the type matches', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'hello', - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test('is false if the id is prefixed only with namespace and the namespace matches', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'foo:world', - _source: { - type: 'hello', - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test(`is false if the id prefix omits the trailing :`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'foo:helloworld', - _source: { - type: 'hello', - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test('is false if the type attribute is missing', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'foo:hello:world', - _source: { - hello: {}, - namespace: 'foo', - } as any, - }) - ).toBeFalsy(); - }); - - test('is false if the type attribute does not match the id', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'foo:hello:world', - _source: { - type: 'jam', - jam: {}, - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test('is false if the namespace attribute does not match the id', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'bar:jam:world', - _source: { - type: 'jam', - jam: {}, - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test('is false if there is no [type] attribute', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'hello', - jam: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - }); - - describe('namespace agnostic type with a namespace', () => { - beforeEach(() => { - typeRegistry.isNamespaceAgnostic.mockReturnValue(true); - }); - - test('is true if the id is prefixed with type and the type matches', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'hello', - hello: {}, - namespace: 'foo', - }, - }) - ).toBeTruthy(); - }); - - test('is false if the id is not prefixed', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'world', - _source: { - type: 'hello', - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test('is false if the id is prefixed with type and namespace', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'foo:hello:world', - _source: { - type: 'hello', - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test(`is false if the type prefix omits the :`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'helloworld', - _source: { - type: 'hello', - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test('is false if the type attribute is missing', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - hello: {}, - namespace: 'foo', - } as any, - }) - ).toBeFalsy(); - }); - - test('is false if the type attribute does not match the id', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'jam', - jam: {}, - hello: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); - - test('is false if there is no [type] attribute', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - expect( - serializer.isRawSavedObject({ - _id: 'hello:world', - _source: { - type: 'hello', - jam: {}, - namespace: 'foo', - }, - }) - ).toBeFalsy(); - }); + describe('single-namespace type with a namespace', () => { + test('generates an id if none is specified and prefixes namespace', () => { + const id = singleNamespaceSerializer.generateRawId('foo', 'goodbye'); + expect(id).toMatch(/^foo:goodbye\:[\w-]+$/); + }); + + test('uses the id that is specified and prefixes the namespace', () => { + const id = singleNamespaceSerializer.generateRawId('foo', 'hello', 'world'); + expect(id).toEqual('foo:hello:world'); }); }); - describe('#generateRawId', () => { - describe('namespaced type without a namespace', () => { - test('generates an id if none is specified', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const id = serializer.generateRawId('', 'goodbye'); - expect(id).toMatch(/goodbye\:[\w-]+$/); - }); - - test('uses the id that is specified', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const id = serializer.generateRawId('', 'hello', 'world'); - expect(id).toMatch('hello:world'); - }); - }); - - describe('namespaced type with a namespace', () => { - test('generates an id if none is specified and prefixes namespace', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const id = serializer.generateRawId('foo', 'goodbye'); - expect(id).toMatch(/foo:goodbye\:[\w-]+$/); - }); - - test('uses the id that is specified and prefixes the namespace', () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const id = serializer.generateRawId('foo', 'hello', 'world'); - expect(id).toMatch('foo:hello:world'); - }); - }); - - describe('namespace agnostic type with a namespace', () => { - test(`generates an id if none is specified and doesn't prefix namespace`, () => { - typeRegistry.isNamespaceAgnostic.mockReturnValue(true); - - const serializer = new SavedObjectsSerializer(typeRegistry); - const id = serializer.generateRawId('foo', 'goodbye'); - expect(id).toMatch(/goodbye\:[\w-]+$/); - }); - - test(`uses the id that is specified and doesn't prefix the namespace`, () => { - const serializer = new SavedObjectsSerializer(typeRegistry); - const id = serializer.generateRawId('foo', 'hello', 'world'); - expect(id).toMatch('hello:world'); - }); + describe('namespace-agnostic type with a namespace', () => { + test(`generates an id if none is specified and doesn't prefix namespace`, () => { + const id = namespaceAgnosticSerializer.generateRawId('foo', 'goodbye'); + expect(id).toMatch(/^goodbye\:[\w-]+$/); + }); + + test(`uses the id that is specified and doesn't prefix the namespace`, () => { + const id = namespaceAgnosticSerializer.generateRawId('foo', 'hello', 'world'); + expect(id).toEqual('hello:world'); }); }); }); diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index 99d6b0c6b59f98..3b19d494d8ecf2 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -49,7 +49,7 @@ export class SavedObjectsSerializer { public isRawSavedObject(rawDoc: SavedObjectsRawDoc) { const { type, namespace } = rawDoc._source; const namespacePrefix = - namespace && !this.registry.isNamespaceAgnostic(type) ? `${namespace}:` : ''; + namespace && this.registry.isSingleNamespace(type) ? `${namespace}:` : ''; return Boolean( type && rawDoc._id.startsWith(`${namespacePrefix}${type}:`) && @@ -64,7 +64,7 @@ export class SavedObjectsSerializer { */ public rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc { const { _id, _source, _seq_no, _primary_term } = doc; - const { type, namespace } = _source; + const { type, namespace, namespaces } = _source; const version = _seq_no != null || _primary_term != null @@ -74,7 +74,8 @@ export class SavedObjectsSerializer { return { type, id: this.trimIdPrefix(namespace, type, _id), - ...(namespace && !this.registry.isNamespaceAgnostic(type) && { namespace }), + ...(namespace && this.registry.isSingleNamespace(type) && { namespace }), + ...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }), attributes: _source[type], references: _source.references || [], ...(_source.migrationVersion && { migrationVersion: _source.migrationVersion }), @@ -93,6 +94,7 @@ export class SavedObjectsSerializer { id, type, namespace, + namespaces, attributes, migrationVersion, updated_at, @@ -103,7 +105,8 @@ export class SavedObjectsSerializer { [type]: attributes, type, references, - ...(namespace && !this.registry.isNamespaceAgnostic(type) && { namespace }), + ...(namespace && this.registry.isSingleNamespace(type) && { namespace }), + ...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }), ...(migrationVersion && { migrationVersion }), ...(updated_at && { updated_at }), }; @@ -124,7 +127,7 @@ export class SavedObjectsSerializer { */ public generateRawId(namespace: string | undefined, type: string, id?: string) { const namespacePrefix = - namespace && !this.registry.isNamespaceAgnostic(type) ? `${namespace}:` : ''; + namespace && this.registry.isSingleNamespace(type) ? `${namespace}:` : ''; return `${namespacePrefix}${type}:${id || uuid.v1()}`; } @@ -133,7 +136,7 @@ export class SavedObjectsSerializer { assertNonEmptyString(type, 'saved object type'); const namespacePrefix = - namespace && !this.registry.isNamespaceAgnostic(type) ? `${namespace}:` : ''; + namespace && this.registry.isSingleNamespace(type) ? `${namespace}:` : ''; const prefix = `${namespacePrefix}${type}:`; if (!id.startsWith(prefix)) { diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index dfaec127ba1593..7ea61f67e9496b 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -36,6 +36,7 @@ export interface SavedObjectsRawDoc { export interface SavedObjectsRawDocSource { type: string; namespace?: string; + namespaces?: string[]; migrationVersion?: SavedObjectsMigrationVersion; updated_at?: string; references?: SavedObjectReference[]; @@ -54,6 +55,7 @@ interface SavedObjectDoc { id?: string; // NOTE: SavedObjectDoc is used for uncreated objects where `id` is optional type: string; namespace?: string; + namespaces?: string[]; migrationVersion?: SavedObjectsMigrationVersion; version?: string; updated_at?: string; diff --git a/src/core/server/saved_objects/service/lib/__snapshots__/repository.test.js.snap b/src/core/server/saved_objects/service/lib/__snapshots__/repository.test.js.snap deleted file mode 100644 index 609906c97d599f..00000000000000 --- a/src/core/server/saved_objects/service/lib/__snapshots__/repository.test.js.snap +++ /dev/null @@ -1,5 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SavedObjectsRepository #deleteByNamespace requires namespace to be a string 1`] = `"namespace is required, and must be a string"`; - -exports[`SavedObjectsRepository #deleteByNamespace requires namespace to be defined 1`] = `"namespace is required, and must be a string"`; diff --git a/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts b/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts index 2fd9b487f470a6..1fdebd87397eb5 100644 --- a/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts +++ b/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts @@ -100,6 +100,38 @@ describe('savedObjectsClient/decorateEsError', () => { expect(SavedObjectsErrorHelpers.isBadRequestError(error)).toBe(true); }); + describe('when es.BadRequest has a reason', () => { + it('makes a SavedObjectsClient/esCannotExecuteScriptError error when script context is disabled', () => { + const error = new esErrors.BadRequest(); + (error as Record).body = { + error: { reason: 'cannot execute scripts using [update] context' }, + }; + expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(false); + expect(decorateEsError(error)).toBe(error); + expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(true); + expect(SavedObjectsErrorHelpers.isBadRequestError(error)).toBe(false); + }); + + it('makes a SavedObjectsClient/esCannotExecuteScriptError error when inline scripts are disabled', () => { + const error = new esErrors.BadRequest(); + (error as Record).body = { + error: { reason: 'cannot execute [inline] scripts' }, + }; + expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(false); + expect(decorateEsError(error)).toBe(error); + expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(true); + expect(SavedObjectsErrorHelpers.isBadRequestError(error)).toBe(false); + }); + + it('makes a SavedObjectsClient/BadRequest error for any other reason', () => { + const error = new esErrors.BadRequest(); + (error as Record).body = { error: { reason: 'some other reason' } }; + expect(SavedObjectsErrorHelpers.isBadRequestError(error)).toBe(false); + expect(decorateEsError(error)).toBe(error); + expect(SavedObjectsErrorHelpers.isBadRequestError(error)).toBe(true); + }); + }); + it('returns other errors as Boom errors', () => { const error = new Error(); expect(error).not.toHaveProperty('isBoom'); diff --git a/src/core/server/saved_objects/service/lib/decorate_es_error.ts b/src/core/server/saved_objects/service/lib/decorate_es_error.ts index eb9bc896364350..e57f08aa7a5274 100644 --- a/src/core/server/saved_objects/service/lib/decorate_es_error.ts +++ b/src/core/server/saved_objects/service/lib/decorate_es_error.ts @@ -35,6 +35,8 @@ const { NotFound, BadRequest, } = legacyElasticsearch.errors; +const SCRIPT_CONTEXT_DISABLED_REGEX = /(?:cannot execute scripts using \[)([a-z]*)(?:\] context)/; +const INLINE_SCRIPTS_DISABLED_MESSAGE = 'cannot execute [inline] scripts'; import { SavedObjectsErrorHelpers } from './errors'; @@ -43,7 +45,7 @@ export function decorateEsError(error: Error) { throw new Error('Expected an instance of Error'); } - const { reason } = get(error, 'body.error', { reason: undefined }); + const { reason } = get(error, 'body.error', { reason: undefined }) as { reason?: string }; if ( error instanceof ConnectionFault || error instanceof ServiceUnavailable || @@ -74,6 +76,12 @@ export function decorateEsError(error: Error) { } if (error instanceof BadRequest) { + if ( + SCRIPT_CONTEXT_DISABLED_REGEX.test(reason || '') || + reason === INLINE_SCRIPTS_DISABLED_MESSAGE + ) { + return SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError(error, reason); + } return SavedObjectsErrorHelpers.decorateBadRequestError(error, reason); } diff --git a/src/core/server/saved_objects/service/lib/errors.test.ts b/src/core/server/saved_objects/service/lib/errors.test.ts index 12fc913f930906..4a43835d795d1d 100644 --- a/src/core/server/saved_objects/service/lib/errors.test.ts +++ b/src/core/server/saved_objects/service/lib/errors.test.ts @@ -34,6 +34,7 @@ describe('savedObjectsClient/errorTypes', () => { }); it('has boom properties', () => { + expect(errorObj).toHaveProperty('isBoom', true); expect(errorObj.output.payload).toMatchObject({ statusCode: 400, message: "Unsupported saved object type: 'someType': Bad Request", @@ -57,6 +58,7 @@ describe('savedObjectsClient/errorTypes', () => { }); it('has boom properties', () => { + expect(errorObj).toHaveProperty('isBoom', true); expect(errorObj.output.payload).toMatchObject({ statusCode: 400, message: 'test reason message: Bad Request', @@ -80,14 +82,7 @@ describe('savedObjectsClient/errorTypes', () => { it('adds boom properties', () => { const error = SavedObjectsErrorHelpers.decorateBadRequestError(new Error()); - expect(typeof error.output).toBe('object'); - expect(error.output.statusCode).toBe(400); - }); - - it('preserves boom properties of input', () => { - const error = Boom.notFound(); - SavedObjectsErrorHelpers.decorateBadRequestError(error); - expect(error.output.statusCode).toBe(404); + expect(error).toHaveProperty('isBoom', true); }); describe('error.output', () => { @@ -95,6 +90,7 @@ describe('savedObjectsClient/errorTypes', () => { const error = SavedObjectsErrorHelpers.decorateBadRequestError(new Error('foobar')); expect(error.output.payload).toHaveProperty('message', 'foobar'); }); + it('prefixes message with passed reason', () => { const error = SavedObjectsErrorHelpers.decorateBadRequestError( new Error('foobar'), @@ -102,13 +98,21 @@ describe('savedObjectsClient/errorTypes', () => { ); expect(error.output.payload).toHaveProperty('message', 'biz: foobar'); }); + it('sets statusCode to 400', () => { const error = SavedObjectsErrorHelpers.decorateBadRequestError(new Error('foo')); expect(error.output).toHaveProperty('statusCode', 400); }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + SavedObjectsErrorHelpers.decorateBadRequestError(error); + expect(error.output).toHaveProperty('statusCode', 404); + }); }); }); }); + describe('NotAuthorized error', () => { describe('decorateNotAuthorizedError', () => { it('returns original object', () => { @@ -125,14 +129,7 @@ describe('savedObjectsClient/errorTypes', () => { it('adds boom properties', () => { const error = SavedObjectsErrorHelpers.decorateNotAuthorizedError(new Error()); - expect(typeof error.output).toBe('object'); - expect(error.output.statusCode).toBe(401); - }); - - it('preserves boom properties of input', () => { - const error = Boom.notFound(); - SavedObjectsErrorHelpers.decorateNotAuthorizedError(error); - expect(error.output.statusCode).toBe(404); + expect(error).toHaveProperty('isBoom', true); }); describe('error.output', () => { @@ -140,6 +137,7 @@ describe('savedObjectsClient/errorTypes', () => { const error = SavedObjectsErrorHelpers.decorateNotAuthorizedError(new Error('foobar')); expect(error.output.payload).toHaveProperty('message', 'foobar'); }); + it('prefixes message with passed reason', () => { const error = SavedObjectsErrorHelpers.decorateNotAuthorizedError( new Error('foobar'), @@ -147,13 +145,21 @@ describe('savedObjectsClient/errorTypes', () => { ); expect(error.output.payload).toHaveProperty('message', 'biz: foobar'); }); + it('sets statusCode to 401', () => { const error = SavedObjectsErrorHelpers.decorateNotAuthorizedError(new Error('foo')); expect(error.output).toHaveProperty('statusCode', 401); }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + SavedObjectsErrorHelpers.decorateNotAuthorizedError(error); + expect(error.output).toHaveProperty('statusCode', 404); + }); }); }); }); + describe('Forbidden error', () => { describe('decorateForbiddenError', () => { it('returns original object', () => { @@ -170,14 +176,7 @@ describe('savedObjectsClient/errorTypes', () => { it('adds boom properties', () => { const error = SavedObjectsErrorHelpers.decorateForbiddenError(new Error()); - expect(typeof error.output).toBe('object'); - expect(error.output.statusCode).toBe(403); - }); - - it('preserves boom properties of input', () => { - const error = Boom.notFound(); - SavedObjectsErrorHelpers.decorateForbiddenError(error); - expect(error.output.statusCode).toBe(404); + expect(error).toHaveProperty('isBoom', true); }); describe('error.output', () => { @@ -185,17 +184,26 @@ describe('savedObjectsClient/errorTypes', () => { const error = SavedObjectsErrorHelpers.decorateForbiddenError(new Error('foobar')); expect(error.output.payload).toHaveProperty('message', 'foobar'); }); + it('prefixes message with passed reason', () => { const error = SavedObjectsErrorHelpers.decorateForbiddenError(new Error('foobar'), 'biz'); expect(error.output.payload).toHaveProperty('message', 'biz: foobar'); }); + it('sets statusCode to 403', () => { const error = SavedObjectsErrorHelpers.decorateForbiddenError(new Error('foo')); expect(error.output).toHaveProperty('statusCode', 403); }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + SavedObjectsErrorHelpers.decorateForbiddenError(error); + expect(error.output).toHaveProperty('statusCode', 404); + }); }); }); }); + describe('NotFound error', () => { describe('createGenericNotFoundError', () => { it('makes an error identifiable as a NotFound error', () => { @@ -203,11 +211,9 @@ describe('savedObjectsClient/errorTypes', () => { expect(SavedObjectsErrorHelpers.isNotFoundError(error)).toBe(true); }); - it('is a boom error, has boom properties', () => { + it('returns a boom error', () => { const error = SavedObjectsErrorHelpers.createGenericNotFoundError(); - expect(error).toHaveProperty('isBoom'); - expect(typeof error.output).toBe('object'); - expect(error.output.statusCode).toBe(404); + expect(error).toHaveProperty('isBoom', true); }); describe('error.output', () => { @@ -215,6 +221,7 @@ describe('savedObjectsClient/errorTypes', () => { const error = SavedObjectsErrorHelpers.createGenericNotFoundError(); expect(error.output.payload).toHaveProperty('message', 'Not Found'); }); + it('sets statusCode to 404', () => { const error = SavedObjectsErrorHelpers.createGenericNotFoundError(); expect(error.output).toHaveProperty('statusCode', 404); @@ -222,6 +229,7 @@ describe('savedObjectsClient/errorTypes', () => { }); }); }); + describe('Conflict error', () => { describe('decorateConflictError', () => { it('returns original object', () => { @@ -238,14 +246,7 @@ describe('savedObjectsClient/errorTypes', () => { it('adds boom properties', () => { const error = SavedObjectsErrorHelpers.decorateConflictError(new Error()); - expect(typeof error.output).toBe('object'); - expect(error.output.statusCode).toBe(409); - }); - - it('preserves boom properties of input', () => { - const error = Boom.notFound(); - SavedObjectsErrorHelpers.decorateConflictError(error); - expect(error.output.statusCode).toBe(404); + expect(error).toHaveProperty('isBoom', true); }); describe('error.output', () => { @@ -253,17 +254,77 @@ describe('savedObjectsClient/errorTypes', () => { const error = SavedObjectsErrorHelpers.decorateConflictError(new Error('foobar')); expect(error.output.payload).toHaveProperty('message', 'foobar'); }); + it('prefixes message with passed reason', () => { const error = SavedObjectsErrorHelpers.decorateConflictError(new Error('foobar'), 'biz'); expect(error.output.payload).toHaveProperty('message', 'biz: foobar'); }); + it('sets statusCode to 409', () => { const error = SavedObjectsErrorHelpers.decorateConflictError(new Error('foo')); expect(error.output).toHaveProperty('statusCode', 409); }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + SavedObjectsErrorHelpers.decorateConflictError(error); + expect(error.output).toHaveProperty('statusCode', 404); + }); + }); + }); + }); + + describe('EsCannotExecuteScript error', () => { + describe('decorateEsCannotExecuteScriptError', () => { + it('returns original object', () => { + const error = new Error(); + expect(SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError(error)).toBe(error); + }); + + it('makes the error identifiable as a EsCannotExecuteScript error', () => { + const error = new Error(); + expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(false); + SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError(error); + expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(true); + }); + + it('adds boom properties', () => { + const error = SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError(new Error()); + expect(error).toHaveProperty('isBoom', true); + }); + + describe('error.output', () => { + it('defaults to message of error', () => { + const error = SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError( + new Error('foobar') + ); + expect(error.output.payload).toHaveProperty('message', 'foobar'); + }); + + it('prefixes message with passed reason', () => { + const error = SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError( + new Error('foobar'), + 'biz' + ); + expect(error.output.payload).toHaveProperty('message', 'biz: foobar'); + }); + + it('sets statusCode to 501', () => { + const error = SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError( + new Error('foo') + ); + expect(error.output).toHaveProperty('statusCode', 400); + }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError(error); + expect(error.output).toHaveProperty('statusCode', 404); + }); }); }); }); + describe('EsUnavailable error', () => { describe('decorateEsUnavailableError', () => { it('returns original object', () => { @@ -280,14 +341,7 @@ describe('savedObjectsClient/errorTypes', () => { it('adds boom properties', () => { const error = SavedObjectsErrorHelpers.decorateEsUnavailableError(new Error()); - expect(typeof error.output).toBe('object'); - expect(error.output.statusCode).toBe(503); - }); - - it('preserves boom properties of input', () => { - const error = Boom.notFound(); - SavedObjectsErrorHelpers.decorateEsUnavailableError(error); - expect(error.output.statusCode).toBe(404); + expect(error).toHaveProperty('isBoom', true); }); describe('error.output', () => { @@ -295,6 +349,7 @@ describe('savedObjectsClient/errorTypes', () => { const error = SavedObjectsErrorHelpers.decorateEsUnavailableError(new Error('foobar')); expect(error.output.payload).toHaveProperty('message', 'foobar'); }); + it('prefixes message with passed reason', () => { const error = SavedObjectsErrorHelpers.decorateEsUnavailableError( new Error('foobar'), @@ -302,13 +357,21 @@ describe('savedObjectsClient/errorTypes', () => { ); expect(error.output.payload).toHaveProperty('message', 'biz: foobar'); }); + it('sets statusCode to 503', () => { const error = SavedObjectsErrorHelpers.decorateEsUnavailableError(new Error('foo')); expect(error.output).toHaveProperty('statusCode', 503); }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + SavedObjectsErrorHelpers.decorateEsUnavailableError(error); + expect(error.output).toHaveProperty('statusCode', 404); + }); }); }); }); + describe('General error', () => { describe('decorateGeneralError', () => { it('returns original object', () => { @@ -318,14 +381,7 @@ describe('savedObjectsClient/errorTypes', () => { it('adds boom properties', () => { const error = SavedObjectsErrorHelpers.decorateGeneralError(new Error()); - expect(typeof error.output).toBe('object'); - expect(error.output.statusCode).toBe(500); - }); - - it('preserves boom properties of input', () => { - const error = Boom.notFound(); - SavedObjectsErrorHelpers.decorateGeneralError(error); - expect(error.output.statusCode).toBe(404); + expect(error).toHaveProperty('isBoom', true); }); describe('error.output', () => { @@ -333,10 +389,17 @@ describe('savedObjectsClient/errorTypes', () => { const error = SavedObjectsErrorHelpers.decorateGeneralError(new Error('foobar')); expect(error.output.payload.message).toMatch(/internal server error/i); }); + it('sets statusCode to 500', () => { const error = SavedObjectsErrorHelpers.decorateGeneralError(new Error('foo')); expect(error.output).toHaveProperty('statusCode', 500); }); + + it('preserves boom properties of input', () => { + const error = Boom.notFound(); + SavedObjectsErrorHelpers.decorateGeneralError(error); + expect(error.output).toHaveProperty('statusCode', 404); + }); }); }); }); @@ -363,9 +426,7 @@ describe('savedObjectsClient/errorTypes', () => { it('returns a boom error', () => { const error = SavedObjectsErrorHelpers.createEsAutoCreateIndexError(); - expect(error).toHaveProperty('isBoom'); - expect(typeof error.output).toBe('object'); - expect(error.output.statusCode).toBe(503); + expect(error).toHaveProperty('isBoom', true); }); describe('error.output', () => { @@ -373,6 +434,7 @@ describe('savedObjectsClient/errorTypes', () => { const error = SavedObjectsErrorHelpers.createEsAutoCreateIndexError(); expect(error.output.payload).toHaveProperty('message', 'Automatic index creation failed'); }); + it('sets statusCode to 503', () => { const error = SavedObjectsErrorHelpers.createEsAutoCreateIndexError(); expect(error.output).toHaveProperty('statusCode', 503); diff --git a/src/core/server/saved_objects/service/lib/errors.ts b/src/core/server/saved_objects/service/lib/errors.ts index e9138e9b8a3477..478c6b6d26d538 100644 --- a/src/core/server/saved_objects/service/lib/errors.ts +++ b/src/core/server/saved_objects/service/lib/errors.ts @@ -33,6 +33,8 @@ const CODE_REQUEST_ENTITY_TOO_LARGE = 'SavedObjectsClient/requestEntityTooLarge' const CODE_NOT_FOUND = 'SavedObjectsClient/notFound'; // 409 - Conflict const CODE_CONFLICT = 'SavedObjectsClient/conflict'; +// 400 - Es Cannot Execute Script +const CODE_ES_CANNOT_EXECUTE_SCRIPT = 'SavedObjectsClient/esCannotExecuteScript'; // 503 - Es Unavailable const CODE_ES_UNAVAILABLE = 'SavedObjectsClient/esUnavailable'; // 503 - Unable to automatically create index because of action.auto_create_index setting @@ -152,10 +154,24 @@ export class SavedObjectsErrorHelpers { return decorate(error, CODE_CONFLICT, 409, reason); } + public static createConflictError(type: string, id: string) { + return SavedObjectsErrorHelpers.decorateConflictError( + Boom.conflict(`Saved object [${type}/${id}] conflict`) + ); + } + public static isConflictError(error: Error | DecoratedError) { return isSavedObjectsClientError(error) && error[code] === CODE_CONFLICT; } + public static decorateEsCannotExecuteScriptError(error: Error, reason?: string) { + return decorate(error, CODE_ES_CANNOT_EXECUTE_SCRIPT, 400, reason); + } + + public static isEsCannotExecuteScriptError(error: Error | DecoratedError) { + return isSavedObjectsClientError(error) && error[code] === CODE_ES_CANNOT_EXECUTE_SCRIPT; + } + public static decorateEsUnavailableError(error: Error, reason?: string) { return decorate(error, CODE_ES_UNAVAILABLE, 503, reason); } diff --git a/src/core/server/saved_objects/service/lib/included_fields.test.ts b/src/core/server/saved_objects/service/lib/included_fields.test.ts index 40d6552c2ad5f3..ced99361f1ea00 100644 --- a/src/core/server/saved_objects/service/lib/included_fields.test.ts +++ b/src/core/server/saved_objects/service/lib/included_fields.test.ts @@ -26,7 +26,7 @@ describe('includedFields', () => { it('accepts type string', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(7); + expect(fields).toHaveLength(8); expect(fields).toContain('type'); }); @@ -37,6 +37,7 @@ Array [ "config.foo", "secret.foo", "namespace", + "namespaces", "type", "references", "migrationVersion", @@ -48,14 +49,14 @@ Array [ it('accepts field as string', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(7); + expect(fields).toHaveLength(8); expect(fields).toContain('config.foo'); }); it('accepts fields as an array', () => { const fields = includedFields('config', ['foo', 'bar']); - expect(fields).toHaveLength(9); + expect(fields).toHaveLength(10); expect(fields).toContain('config.foo'); expect(fields).toContain('config.bar'); }); @@ -69,6 +70,7 @@ Array [ "secret.foo", "secret.bar", "namespace", + "namespaces", "type", "references", "migrationVersion", @@ -81,31 +83,37 @@ Array [ it('includes namespace', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(7); + expect(fields).toHaveLength(8); expect(fields).toContain('namespace'); }); + it('includes namespaces', () => { + const fields = includedFields('config', 'foo'); + expect(fields).toHaveLength(8); + expect(fields).toContain('namespaces'); + }); + it('includes references', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(7); + expect(fields).toHaveLength(8); expect(fields).toContain('references'); }); it('includes migrationVersion', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(7); + expect(fields).toHaveLength(8); expect(fields).toContain('migrationVersion'); }); it('includes updated_at', () => { const fields = includedFields('config', 'foo'); - expect(fields).toHaveLength(7); + expect(fields).toHaveLength(8); expect(fields).toContain('updated_at'); }); it('uses wildcard when type is not provided', () => { const fields = includedFields(undefined, 'foo'); - expect(fields).toHaveLength(7); + expect(fields).toHaveLength(8); expect(fields).toContain('*.foo'); }); @@ -113,7 +121,7 @@ Array [ it('includes legacy field path', () => { const fields = includedFields('config', ['foo', 'bar']); - expect(fields).toHaveLength(9); + expect(fields).toHaveLength(10); expect(fields).toContain('foo'); expect(fields).toContain('bar'); }); diff --git a/src/core/server/saved_objects/service/lib/included_fields.ts b/src/core/server/saved_objects/service/lib/included_fields.ts index f372db5a1a6351..c50ac225940087 100644 --- a/src/core/server/saved_objects/service/lib/included_fields.ts +++ b/src/core/server/saved_objects/service/lib/included_fields.ts @@ -37,6 +37,7 @@ export function includedFields(type: string | string[] = '*', fields?: string[] return [...acc, ...sourceFields.map(f => `${t}.${f}`)]; }, []) .concat('namespace') + .concat('namespaces') .concat('type') .concat('references') .concat('migrationVersion') diff --git a/src/core/server/saved_objects/service/lib/repository.mock.ts b/src/core/server/saved_objects/service/lib/repository.mock.ts index e69c0ff37d1bef..afef378b7307b2 100644 --- a/src/core/server/saved_objects/service/lib/repository.mock.ts +++ b/src/core/server/saved_objects/service/lib/repository.mock.ts @@ -28,6 +28,8 @@ const create = (): jest.Mocked => ({ find: jest.fn(), get: jest.fn(), update: jest.fn(), + addToNamespaces: jest.fn(), + deleteFromNamespaces: jest.fn(), deleteByNamespace: jest.fn(), incrementCounter: jest.fn(), }); diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 2e5eeec04e0a8d..927171438ae996 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import _ from 'lodash'; import { SavedObjectsRepository } from './repository'; import * as getSearchDslNS from './search_dsl/search_dsl'; @@ -30,162 +29,31 @@ jest.mock('./search_dsl/search_dsl', () => ({ getSearchDsl: jest.fn() })); // 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. +const createBadRequestError = (...args) => + SavedObjectsErrorHelpers.createBadRequestError(...args).output.payload; +const createConflictError = (...args) => + SavedObjectsErrorHelpers.createConflictError(...args).output.payload; +const createGenericNotFoundError = (...args) => + SavedObjectsErrorHelpers.createGenericNotFoundError(...args).output.payload; +const createUnsupportedTypeError = (...args) => + SavedObjectsErrorHelpers.createUnsupportedTypeError(...args).output.payload; + describe('SavedObjectsRepository', () => { let callAdminCluster; let savedObjectsRepository; let migrator; + let serializer; const mockTimestamp = '2017-08-14T15:49:14.886Z'; const mockTimestampFields = { updated_at: mockTimestamp }; const mockVersionProps = { _seq_no: 1, _primary_term: 1 }; const mockVersion = encodeHitVersion(mockVersionProps); - const noNamespaceSearchResults = { - hits: { - total: 4, - hits: [ - { - _index: '.kibana', - _id: 'index-pattern:logstash-*', - _score: 1, - ...mockVersionProps, - _source: { - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { - title: 'logstash-*', - timeFieldName: '@timestamp', - notExpandable: true, - }, - }, - }, - { - _index: '.kibana', - _id: 'config:6.0.0-alpha1', - _score: 1, - ...mockVersionProps, - _source: { - type: 'config', - ...mockTimestampFields, - config: { - buildNum: 8467, - defaultIndex: 'logstash-*', - }, - }, - }, - { - _index: '.kibana', - _id: 'index-pattern:stocks-*', - _score: 1, - ...mockVersionProps, - _source: { - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { - title: 'stocks-*', - timeFieldName: '@timestamp', - notExpandable: true, - }, - }, - }, - { - _index: '.kibana', - _id: 'globaltype:something', - _score: 1, - ...mockVersionProps, - _source: { - type: 'globaltype', - ...mockTimestampFields, - globaltype: { - name: 'bar', - }, - }, - }, - ], - }, - }; - - const namespacedSearchResults = { - hits: { - total: 4, - hits: [ - { - _index: '.kibana', - _id: 'foo-namespace:index-pattern:logstash-*', - _score: 1, - ...mockVersionProps, - _source: { - namespace: 'foo-namespace', - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { - title: 'logstash-*', - timeFieldName: '@timestamp', - notExpandable: true, - }, - }, - }, - { - _index: '.kibana', - _id: 'foo-namespace:config:6.0.0-alpha1', - _score: 1, - ...mockVersionProps, - _source: { - namespace: 'foo-namespace', - type: 'config', - ...mockTimestampFields, - config: { - buildNum: 8467, - defaultIndex: 'logstash-*', - }, - }, - }, - { - _index: '.kibana', - _id: 'foo-namespace:index-pattern:stocks-*', - _score: 1, - ...mockVersionProps, - _source: { - namespace: 'foo-namespace', - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { - title: 'stocks-*', - timeFieldName: '@timestamp', - notExpandable: true, - }, - }, - }, - { - _index: '.kibana', - _id: 'globaltype:something', - _score: 1, - ...mockVersionProps, - _source: { - type: 'globaltype', - ...mockTimestampFields, - globaltype: { - name: 'bar', - }, - }, - }, - ], - }, - }; - const deleteByQueryResults = { - took: 27, - timed_out: false, - total: 23, - deleted: 23, - batches: 1, - version_conflicts: 0, - noops: 0, - retries: { bulk: 0, search: 0 }, - throttled_millis: 0, - requests_per_second: -1, - throttled_until_millis: 0, - failures: [], - }; + const CUSTOM_INDEX_TYPE = 'customIndex'; + const NAMESPACE_AGNOSTIC_TYPE = 'globalType'; + const MULTI_NAMESPACE_TYPE = 'shareableType'; + const MULTI_NAMESPACE_CUSTOM_INDEX_TYPE = 'shareableTypeCustomIndex'; + const HIDDEN_TYPE = 'hiddenType'; const mappings = { properties: { @@ -194,43 +62,47 @@ describe('SavedObjectsRepository', () => { type: 'keyword', }, }, - foo: { + 'index-pattern': { properties: { - type: 'keyword', + someField: { + type: 'keyword', + }, }, }, - bar: { + dashboard: { properties: { - type: 'keyword', + otherField: { + type: 'keyword', + }, }, }, - baz: { + [CUSTOM_INDEX_TYPE]: { properties: { type: 'keyword', }, }, - 'index-pattern': { + [NAMESPACE_AGNOSTIC_TYPE]: { properties: { - someField: { + yetAnotherField: { type: 'keyword', }, }, }, - dashboard: { + [MULTI_NAMESPACE_TYPE]: { properties: { - otherField: { + evenYetAnotherField: { type: 'keyword', }, }, }, - globaltype: { + [MULTI_NAMESPACE_CUSTOM_INDEX_TYPE]: { properties: { - yetAnotherField: { + evenYetAnotherField: { type: 'keyword', }, }, }, - hiddenType: { + [HIDDEN_TYPE]: { properties: { someField: { type: 'keyword', @@ -240,96 +112,97 @@ describe('SavedObjectsRepository', () => { }, }; - const typeRegistry = new SavedObjectTypeRegistry(); - typeRegistry.registerType({ - name: 'config', - hidden: false, - namespaceAgnostic: false, - mappings: { - properties: { - type: 'keyword', - }, - }, + const createType = type => ({ + name: type, + mappings: { properties: mappings.properties[type].properties }, }); - typeRegistry.registerType({ - name: 'index-pattern', - hidden: false, - namespaceAgnostic: false, - mappings: { - properties: { - someField: { - type: 'keyword', - }, - }, - }, + + const registry = new SavedObjectTypeRegistry(); + registry.registerType(createType('config')); + registry.registerType(createType('index-pattern')); + registry.registerType(createType('dashboard')); + registry.registerType({ + ...createType(CUSTOM_INDEX_TYPE), + indexPattern: 'custom', }); - typeRegistry.registerType({ - name: 'dashboard', - hidden: false, - namespaceAgnostic: false, - mappings: { - properties: { - otherField: { - type: 'keyword', - }, - }, - }, + registry.registerType({ + ...createType(NAMESPACE_AGNOSTIC_TYPE), + namespaceType: 'agnostic', }); - typeRegistry.registerType({ - name: 'globaltype', - hidden: false, - namespaceAgnostic: true, - mappings: { - properties: { - yetAnotherField: { - type: 'keyword', - }, - }, - }, + registry.registerType({ + ...createType(MULTI_NAMESPACE_TYPE), + namespaceType: 'multiple', }); - typeRegistry.registerType({ - name: 'foo', - hidden: false, - namespaceAgnostic: true, - mappings: { - properties: { - type: 'keyword', - }, - }, + registry.registerType({ + ...createType(MULTI_NAMESPACE_CUSTOM_INDEX_TYPE), + namespaceType: 'multiple', + indexPattern: 'custom', }); - typeRegistry.registerType({ - name: 'bar', - hidden: false, - namespaceAgnostic: true, - mappings: { - properties: { - type: 'keyword', - }, - }, + registry.registerType({ + ...createType(HIDDEN_TYPE), + hidden: true, + namespaceType: 'agnostic', }); - typeRegistry.registerType({ - name: 'baz', - hidden: false, - namespaceAgnostic: false, - indexPattern: 'beats', - mappings: { - properties: { - type: 'keyword', - }, + + const getMockGetResponse = ({ type, id, references, namespace }) => ({ + // 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'] }), + type, + [type]: { title: 'Testing' }, + references, + specialProperty: 'specialValue', + ...mockTimestampFields, }, }); - typeRegistry.registerType({ - name: 'hiddenType', - hidden: true, - namespaceAgnostic: true, - mappings: { - properties: { - someField: { - type: 'keyword', - }, - }, + + const getMockMgetResponse = (objects, namespace) => ({ + status: 200, + docs: objects.map(obj => + obj.found === false ? obj : getMockGetResponse({ ...obj, namespace }) + ), + }); + + const expectClusterCalls = (...actions) => { + for (let i = 0; i < actions.length; i++) { + expect(callAdminCluster).toHaveBeenNthCalledWith(i + 1, actions[i], expect.any(Object)); + } + expect(callAdminCluster).toHaveBeenCalledTimes(actions.length); + }; + const expectClusterCallArgs = (args, n = 1) => { + expect(callAdminCluster).toHaveBeenNthCalledWith( + n, + expect.any(String), + expect.objectContaining(args) + ); + }; + + expect.extend({ + toBeDocumentWithoutError(received, type, id) { + if (received.type === type && received.id === id && !received.error) { + return { message: () => `expected type and id not to match without error`, pass: true }; + } else { + return { message: () => `expected type and id to match without error`, pass: false }; + } }, }); + const expectSuccess = ({ type, id }) => expect.toBeDocumentWithoutError(type, id); + const expectError = ({ type, id }) => ({ type, id, error: expect.any(Object) }); + const expectErrorResult = ({ type, id }, error) => ({ type, id, error }); + const expectErrorNotFound = obj => + expectErrorResult(obj, createGenericNotFoundError(obj.type, obj.id)); + const expectErrorConflict = obj => expectErrorResult(obj, createConflictError(obj.type, obj.id)); + const expectErrorInvalidType = obj => + expectErrorResult(obj, createUnsupportedTypeError(obj.type, obj.id)); + + const expectMigrationArgs = (args, contains = true, n = 1) => { + const obj = contains ? expect.objectContaining(args) : expect.not.objectContaining(args); + expect(migrator.migrateDocument).toHaveBeenNthCalledWith(n, obj); + }; beforeEach(() => { callAdminCluster = jest.fn(); @@ -338,16 +211,28 @@ describe('SavedObjectsRepository', () => { runMigrations: async () => ({ status: 'skipped' }), }; - const serializer = new SavedObjectsSerializer(typeRegistry); - const allTypes = typeRegistry.getAllTypes().map(type => type.name); - const allowedTypes = [...new Set(allTypes.filter(type => !typeRegistry.isHidden(type)))]; + // create a mock serializer "shim" so we can track function calls, but use the real serializer's implementation + serializer = { + isRawSavedObject: jest.fn(), + rawToSavedObject: jest.fn(), + savedObjectToRaw: jest.fn(), + generateRawId: jest.fn(), + trimIdPrefix: jest.fn(), + }; + const _serializer = new SavedObjectsSerializer(registry); + Object.keys(serializer).forEach(key => { + serializer[key].mockImplementation((...args) => _serializer[key](...args)); + }); + + const allTypes = registry.getAllTypes().map(type => type.name); + const allowedTypes = [...new Set(allTypes.filter(type => !registry.isHidden(type)))]; savedObjectsRepository = new SavedObjectsRepository({ index: '.kibana-test', mappings, callCluster: callAdminCluster, migrator, - typeRegistry, + typeRegistry: registry, serializer, allowedTypes, }); @@ -356,2644 +241,2819 @@ describe('SavedObjectsRepository', () => { getSearchDslNS.getSearchDsl.mockReset(); }); - describe('#create', () => { - beforeEach(() => { - callAdminCluster.mockImplementation((method, params) => ({ - _id: params.id, - ...mockVersionProps, - })); - }); + const mockMigrationVersion = { foo: '2.3.4' }; + const mockMigrateDocument = doc => ({ + ...doc, + attributes: { + ...doc.attributes, + ...(doc.attributes?.title && { title: `${doc.attributes.title}!!` }), + }, + migrationVersion: mockMigrationVersion, + references: [{ name: 'search_0', type: 'search', id: '123' }], + }); - it('waits until migrations are complete before proceeding', async () => { - migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled()); + describe('#addToNamespaces', () => { + const id = 'some-id'; + const type = MULTI_NAMESPACE_TYPE; + const currentNs1 = 'default'; + const currentNs2 = 'foo-namespace'; + const newNs1 = 'bar-namespace'; + const newNs2 = 'baz-namespace'; + + const mockGetResponse = (type, id) => { + // mock a document that exists in two namespaces + const mockResponse = getMockGetResponse({ type, id }); + mockResponse._source.namespaces = [currentNs1, currentNs2]; + callAdminCluster.mockResolvedValueOnce(mockResponse); // this._callCluster('get', ...) + }; - await expect( - savedObjectsRepository.create( - 'index-pattern', - { - title: 'Logstash', - }, - { - id: 'logstash-*', - namespace: 'foo-namespace', - } - ) - ).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); + const addToNamespacesSuccess = async (type, id, namespaces, options) => { + mockGetResponse(type, id); // this._callCluster('get', ...) + callAdminCluster.mockResolvedValue({ + _id: `${type}:${id}`, + ...mockVersionProps, + result: 'updated', + }); // this._writeToCluster('update', ...) + const result = await savedObjectsRepository.addToNamespaces(type, id, namespaces, options); + expect(callAdminCluster).toHaveBeenCalledTimes(2); + return result; + }; - it('formats Elasticsearch response', async () => { - const response = await savedObjectsRepository.create( - 'index-pattern', - { - title: 'Logstash', - }, - { - id: 'logstash-*', - namespace: 'foo-namespace', - references: [ - { - name: 'ref_0', - type: 'test', - id: '123', - }, - ], - } - ); + describe('cluster calls', () => { + it(`should use ES get action then update action`, async () => { + await addToNamespacesSuccess(type, id, [newNs1, newNs2]); + expectClusterCalls('get', 'update'); + }); - expect(response).toEqual({ - type: 'index-pattern', - id: 'logstash-*', - ...mockTimestampFields, - version: mockVersion, - attributes: { - title: 'Logstash', - }, - references: [ - { - name: 'ref_0', - type: 'test', - id: '123', - }, - ], + it(`defaults to the version of the existing document`, async () => { + await addToNamespacesSuccess(type, id, [newNs1, newNs2]); + const versionProperties = { + if_seq_no: mockVersionProps._seq_no, + if_primary_term: mockVersionProps._primary_term, + }; + expectClusterCallArgs(versionProperties, 2); + }); + + it(`accepts version`, async () => { + await addToNamespacesSuccess(type, id, [newNs1, newNs2], { + version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }), + }); + expectClusterCallArgs({ if_seq_no: 100, if_primary_term: 200 }, 2); }); - }); - it('should use ES index action', async () => { - await savedObjectsRepository.create('index-pattern', { - id: 'logstash-*', - title: 'Logstash', + it(`defaults to a refresh setting of wait_for`, async () => { + await addToNamespacesSuccess(type, id, [newNs1, newNs2]); + expectClusterCallArgs({ refresh: 'wait_for' }, 2); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith('index', expect.any(Object)); + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + await addToNamespacesSuccess(type, id, [newNs1, newNs2], { refresh }); + expectClusterCallArgs({ refresh }, 2); + }); }); - it('should use default index', async () => { - await savedObjectsRepository.create('index-pattern', { - id: 'logstash-*', - title: 'Logstash', + describe('errors', () => { + const expectNotFoundError = async (type, id, namespaces, options) => { + await expect( + savedObjectsRepository.addToNamespaces(type, id, namespaces, options) + ).rejects.toThrowError(createGenericNotFoundError(type, id)); + }; + const expectBadRequestError = async (type, id, namespaces, message) => { + await expect( + savedObjectsRepository.addToNamespaces(type, id, namespaces) + ).rejects.toThrowError(createBadRequestError(message)); + }; + + it(`throws when type is invalid`, async () => { + await expectNotFoundError('unknownType', id, [newNs1, newNs2]); + expect(callAdminCluster).not.toHaveBeenCalled(); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - 'index', - expect.objectContaining({ - index: '.kibana-test', - }) - ); - }); + it(`throws when type is hidden`, async () => { + await expectNotFoundError(HIDDEN_TYPE, id, [newNs1, newNs2]); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); - it('should use custom index', async () => { - await savedObjectsRepository.create('baz', { - id: 'logstash-*', - title: 'Logstash', + 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, [newNs1, newNs2], message); + expect(callAdminCluster).not.toHaveBeenCalled(); + }; + await test('index-pattern'); + await test(NAMESPACE_AGNOSTIC_TYPE); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - 'index', - expect.objectContaining({ - index: 'beats', - }) - ); - }); + it(`throws when namespaces is an empty array`, async () => { + const test = async namespaces => { + const message = 'namespaces must be a non-empty array of strings'; + await expectBadRequestError(type, id, namespaces, message); + expect(callAdminCluster).not.toHaveBeenCalled(); + }; + await test([]); + }); - it('migrates the doc', async () => { - migrator.migrateDocument = doc => { - doc.attributes.title = doc.attributes.title + '!!'; - doc.migrationVersion = { foo: '2.3.4' }; - doc.references = [{ name: 'search_0', type: 'search', id: '123' }]; - return doc; - }; + it(`throws when ES is unable to find the document during get`, async () => { + callAdminCluster.mockResolvedValue({ found: false }); // this._callCluster('get', ...) + await expectNotFoundError(type, id, [newNs1, newNs2]); + expectClusterCalls('get'); + }); - await savedObjectsRepository.create('index-pattern', { - id: 'logstash-*', - title: 'Logstash', + it(`throws when ES is unable to find the index during get`, async () => { + callAdminCluster.mockResolvedValue({ status: 404 }); // this._callCluster('get', ...) + await expectNotFoundError(type, id, [newNs1, newNs2]); + expectClusterCalls('get'); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - body: { - 'index-pattern': { id: 'logstash-*', title: 'Logstash!!' }, - migrationVersion: { foo: '2.3.4' }, - type: 'index-pattern', - updated_at: '2017-08-14T15:49:14.886Z', - references: [{ name: 'search_0', type: 'search', id: '123' }], - }, + it(`throws when the document exists, but not in this namespace`, async () => { + mockGetResponse(type, id); // this._callCluster('get', ...) + await expectNotFoundError(type, id, [newNs1, newNs2], { + namespace: 'some-other-namespace', + }); + expectClusterCalls('get'); }); - }); - it('defaults to a refresh setting of `wait_for`', async () => { - await savedObjectsRepository.create('index-pattern', { - id: 'logstash-*', - title: 'Logstash', + it(`throws when ES is unable to find the document during update`, async () => { + mockGetResponse(type, id); // this._callCluster('get', ...) + callAdminCluster.mockResolvedValue({ status: 404 }); // this._writeToCluster('update', ...) + await expectNotFoundError(type, id, [newNs1, newNs2]); + expectClusterCalls('get', 'update'); }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: 'wait_for', + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + let callAdminClusterCount = 0; + migrator.runMigrations = jest.fn(async () => + // runMigrations should resolve before callAdminCluster is initiated + expect(callAdminCluster).toHaveBeenCalledTimes(callAdminClusterCount++) + ); + await expect(addToNamespacesSuccess(type, id, [newNs1, newNs2])).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveReturnedTimes(2); }); }); - it('accepts custom refresh settings', async () => { - await savedObjectsRepository.create( - 'index-pattern', - { - id: 'logstash-*', - title: 'Logstash', - }, - { - refresh: true, - } - ); + describe('returns', () => { + it(`returns an empty object on success`, async () => { + const result = await addToNamespacesSuccess(type, id, [newNs1, newNs2]); + expect(result).toEqual({}); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: true, + it(`succeeds when adding existing namespaces`, async () => { + const result = await addToNamespacesSuccess(type, id, [currentNs1]); + expect(result).toEqual({}); }); }); + }); - it('should use create action if ID defined and overwrite=false', async () => { - await savedObjectsRepository.create( - 'index-pattern', - { - title: 'Logstash', - }, - { - id: 'logstash-*', - } - ); + describe('#bulkCreate', () => { + const obj1 = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const obj2 = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; + const namespace = 'foo-namespace'; - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith('create', expect.any(Object)); - }); + const getMockBulkCreateResponse = (objects, namespace) => { + return { + items: objects.map(({ type, id }) => ({ + create: { + _id: `${namespace ? `${namespace}:` : ''}${type}:${id}`, + ...mockVersionProps, + }, + })), + }; + }; - it('allows for id to be provided', async () => { - await savedObjectsRepository.create( - 'index-pattern', - { - title: 'Logstash', - }, - { id: 'logstash-*' } - ); + const bulkCreateSuccess = async (objects, options) => { + const multiNamespaceObjects = + options?.overwrite && + objects.filter(({ type, id }) => registry.isMultiNamespace(type) && id); + if (multiNamespaceObjects?.length) { + const response = getMockMgetResponse(multiNamespaceObjects, options?.namespace); + callAdminCluster.mockResolvedValueOnce(response); // this._callCluster('mget', ...) + } + const response = getMockBulkCreateResponse(objects, options?.namespace); + callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) + const result = await savedObjectsRepository.bulkCreate(objects, options); + expect(callAdminCluster).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 2 : 1); + return result; + }; - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - id: 'index-pattern:logstash-*', - }) - ); + // bulk create calls have two objects for each source -- the action, and the source + const expectClusterCallArgsAction = ( + objects, + { method, _index = expect.any(String), getId = () => expect.any(String) } + ) => { + const body = []; + for (const { type, id } of objects) { + body.push({ [method]: { _index, _id: getId(type, id) } }); + body.push(expect.any(Object)); + } + expectClusterCallArgs({ body }); + }; + + const expectObjArgs = ({ type, attributes, references }, overrides) => [ + expect.any(Object), + expect.objectContaining({ + [type]: attributes, + references, + type, + ...overrides, + ...mockTimestampFields, + }), + ]; + + const expectSuccessResult = obj => ({ + ...obj, + migrationVersion: undefined, + version: mockVersion, + ...mockTimestampFields, }); - it('self-generates an ID', async () => { - await savedObjectsRepository.create('index-pattern', { - title: 'Logstash', + describe('cluster calls', () => { + it(`should use the ES bulk action by default`, async () => { + await bulkCreateSuccess([obj1, obj2]); + expectClusterCalls('bulk'); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/), - }) - ); - }); + it(`should use the ES mget action before bulk action for any types that are multi-namespace, when overwrite=true`, async () => { + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; + await bulkCreateSuccess(objects, { overwrite: true }); + expectClusterCalls('mget', 'bulk'); + const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; + expectClusterCallArgs({ body: { docs } }, 1); + }); - it('prepends namespace to the id and adds namespace to body when providing namespace for namespaced type', async () => { - await savedObjectsRepository.create( - 'index-pattern', - { - title: 'Logstash', - }, - { - id: 'foo-id', - namespace: 'foo-namespace', - } - ); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - id: `foo-namespace:index-pattern:foo-id`, - body: expect.objectContaining({ - [`index-pattern`]: { title: 'Logstash' }, - namespace: 'foo-namespace', - type: 'index-pattern', - updated_at: '2017-08-14T15:49:14.886Z', - }), - }) - ); - }); + it(`should use the ES create method if ID is undefined and overwrite=true`, async () => { + const objects = [obj1, obj2].map(obj => ({ ...obj, id: undefined })); + await bulkCreateSuccess(objects, { overwrite: true }); + expectClusterCallArgsAction(objects, { method: 'create' }); + }); - it(`doesn't prepend namespace to the id or add namespace property when providing no namespace for namespaced type`, async () => { - await savedObjectsRepository.create( - 'index-pattern', - { - title: 'Logstash', - }, - { - id: 'foo-id', - } - ); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - id: `index-pattern:foo-id`, - body: expect.objectContaining({ - [`index-pattern`]: { title: 'Logstash' }, - type: 'index-pattern', - updated_at: '2017-08-14T15:49:14.886Z', - }), - }) - ); - }); + it(`should use the ES create method if ID is undefined and overwrite=false`, async () => { + const objects = [obj1, obj2].map(obj => ({ ...obj, id: undefined })); + await bulkCreateSuccess(objects); + expectClusterCallArgsAction(objects, { method: 'create' }); + }); - it(`doesn't prepend namespace to the id or add namespace property when providing namespace for namespace agnostic type`, async () => { - await savedObjectsRepository.create( - 'globaltype', - { - title: 'Logstash', - }, - { - id: 'foo-id', - namespace: 'foo-namespace', - } - ); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - id: `globaltype:foo-id`, - body: expect.objectContaining({ - [`globaltype`]: { title: 'Logstash' }, - type: 'globaltype', - updated_at: '2017-08-14T15:49:14.886Z', - }), - }) - ); - }); - - it('defaults to empty references array if none are provided', async () => { - await savedObjectsRepository.create( - 'index-pattern', - { - title: 'Logstash', - }, - { - id: 'logstash-*', - } - ); + it(`should use the ES index method if ID is defined and overwrite=true`, async () => { + await bulkCreateSuccess([obj1, obj2], { overwrite: true }); + expectClusterCallArgsAction([obj1, obj2], { method: 'index' }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: expect.objectContaining({ - references: [], - }), - }) - ); - }); - }); + it(`should use the ES create method if ID is defined and overwrite=false`, async () => { + await bulkCreateSuccess([obj1, obj2]); + expectClusterCallArgsAction([obj1, obj2], { method: 'create' }); + }); - describe('#bulkCreate', () => { - it('waits until migrations are complete before proceeding', async () => { - migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled()); - callAdminCluster.mockReturnValue({ - items: [ - { create: { type: 'config', id: 'config:one', _primary_term: 1, _seq_no: 1 } }, - { - create: { - type: 'index-pattern', - id: 'index-pattern:two', - _primary_term: 1, - _seq_no: 1, - }, - }, - ], + it(`formats the ES request`, async () => { + await bulkCreateSuccess([obj1, obj2]); + const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; + expectClusterCallArgs({ body }); }); - await expect( - savedObjectsRepository.bulkCreate([ - { type: 'config', id: 'one', attributes: { title: 'Test One' } }, - { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }, - ]) - ).resolves.toBeDefined(); + it(`adds namespace to request body for any types that are single-namespace`, async () => { + await bulkCreateSuccess([obj1, obj2], { namespace }); + const expected = expect.objectContaining({ namespace }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expectClusterCallArgs({ body }); + }); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); + it(`doesn't add namespace to request body for any types that are not single-namespace`, async () => { + const objects = [ + { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_TYPE }, + ]; + await bulkCreateSuccess(objects, { namespace }); + const expected = expect.not.objectContaining({ namespace: expect.anything() }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expectClusterCallArgs({ body }); + }); - it('formats Elasticsearch request', async () => { - callAdminCluster.mockReturnValue({ - items: [ - { create: { type: 'config', id: 'config:one', _primary_term: 1, _seq_no: 1 } }, - { create: { type: 'index-pattern', id: 'config:two', _primary_term: 1, _seq_no: 1 } }, - ], + it(`adds namespaces to request body for any types that are multi-namespace`, async () => { + const test = async namespace => { + const objects = [obj1, obj2].map(x => ({ ...x, type: MULTI_NAMESPACE_TYPE })); + const namespaces = [namespace ?? 'default']; + await bulkCreateSuccess(objects, { namespace, overwrite: true }); + const expected = expect.objectContaining({ namespaces }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expectClusterCallArgs({ body }, 2); + callAdminCluster.mockReset(); + }; + await test(undefined); + await test(namespace); }); - await savedObjectsRepository.bulkCreate([ - { - type: 'config', - id: 'one', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }, - { - type: 'index-pattern', - id: 'two', - attributes: { title: 'Test Two' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - }, - ]); + it(`doesn't add namespaces to request body for any types that are not multi-namespace`, async () => { + const test = async namespace => { + const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; + await bulkCreateSuccess(objects, { namespace, overwrite: true }); + const expected = expect.not.objectContaining({ namespaces: expect.anything() }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expectClusterCallArgs({ body }); + callAdminCluster.mockReset(); + }; + await test(undefined); + await test(namespace); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - const bulkCalls = callAdminCluster.mock.calls.filter(([path]) => path === 'bulk'); + it(`defaults to a refresh setting of wait_for`, async () => { + await bulkCreateSuccess([obj1, obj2]); + expectClusterCallArgs({ refresh: 'wait_for' }); + }); - expect(bulkCalls.length).toEqual(1); + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + await bulkCreateSuccess([obj1, obj2], { refresh }); + expectClusterCallArgs({ refresh }); + }); - expect(bulkCalls[0][1].body).toEqual([ - { create: { _index: '.kibana-test', _id: 'config:one' } }, - { - type: 'config', - ...mockTimestampFields, - config: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }, - { create: { _index: '.kibana-test', _id: 'index-pattern:two' } }, - { - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { title: 'Test Two' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - }, - ]); - }); + it(`should use default index`, async () => { + await bulkCreateSuccess([obj1, obj2]); + expectClusterCallArgsAction([obj1, obj2], { method: 'create', _index: '.kibana-test' }); + }); - it('defaults to a refresh setting of `wait_for`', async () => { - callAdminCluster.mockReturnValue({ - items: [{ create: { type: 'config', id: 'config:one', _primary_term: 1, _seq_no: 1 } }], + it(`should use custom index`, async () => { + await bulkCreateSuccess([obj1, obj2].map(x => ({ ...x, type: CUSTOM_INDEX_TYPE }))); + expectClusterCallArgsAction([obj1, obj2], { method: 'create', _index: 'custom' }); }); - await savedObjectsRepository.bulkCreate([ - { - type: 'config', - id: 'one', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }, - ]); + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + const getId = (type, id) => `${namespace}:${type}:${id}`; + await bulkCreateSuccess([obj1, obj2], { namespace }); + expectClusterCallArgsAction([obj1, obj2], { method: 'create', getId }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + const getId = (type, id) => `${type}:${id}`; + await bulkCreateSuccess([obj1, obj2]); + expectClusterCallArgsAction([obj1, obj2], { method: 'create', getId }); + }); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: 'wait_for', + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + const getId = (type, id) => `${type}:${id}`; + const objects = [ + { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_TYPE }, + ]; + await bulkCreateSuccess(objects, { namespace }); + expectClusterCallArgsAction(objects, { method: 'create', getId }); }); }); - it('accepts a custom refresh setting', async () => { - callAdminCluster.mockReturnValue({ - items: [ - { create: { type: 'config', id: 'config:one', _primary_term: 1, _seq_no: 1 } }, - { create: { type: 'index-pattern', id: 'config:two', _primary_term: 1, _seq_no: 1 } }, - ], - }); + describe('errors', () => { + const obj3 = { + type: 'dashboard', + id: 'three', + attributes: { title: 'Test Three' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; - await savedObjectsRepository.bulkCreate( - [ - { - type: 'config', - id: 'one', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }, - { - type: 'index-pattern', - id: 'two', - attributes: { title: 'Test Two' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - }, - ], - { - refresh: true, + const bulkCreateError = async (obj, esError, expectedError) => { + const objects = [obj1, obj, obj2]; + const response = getMockBulkCreateResponse(objects); + if (esError) { + response.items[1].create = { error: esError }; } - ); - - expect(callAdminCluster).toHaveBeenCalledTimes(1); + callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) + + const result = await savedObjectsRepository.bulkCreate(objects); + expectClusterCalls('bulk'); + const objCall = esError ? expectObjArgs(obj) : []; + const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; + expectClusterCallArgs({ body }); + expect(result).toEqual({ + saved_objects: [expectSuccess(obj1), expectedError, expectSuccess(obj2)], + }); + }; - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: true, + it(`returns error when type is invalid`, async () => { + const obj = { ...obj3, type: 'unknownType' }; + await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); }); - }); - it('migrates the docs', async () => { - callAdminCluster.mockReturnValue({ - items: [ - { - create: { - error: false, - _id: '1', - _seq_no: 1, - _primary_term: 1, - }, - }, - { - create: { - error: false, - _id: '2', - _seq_no: 1, - _primary_term: 1, - }, - }, - ], + it(`returns error when type is hidden`, async () => { + const obj = { ...obj3, type: HIDDEN_TYPE }; + await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); }); - migrator.migrateDocument = doc => { - doc.attributes.title = doc.attributes.title + '!!'; - doc.migrationVersion = { foo: '2.3.4' }; - doc.references = [{ name: 'search_0', type: 'search', id: '123' }]; - return doc; - }; - - const bulkCreateResp = await savedObjectsRepository.bulkCreate([ - { type: 'config', id: 'one', attributes: { title: 'Test One' } }, - { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }, - ]); - - expect(callAdminCluster).toHaveBeenCalledWith( - 'bulk', - expect.objectContaining({ - body: [ - { create: { _index: '.kibana-test', _id: 'config:one' } }, - { - type: 'config', - ...mockTimestampFields, - config: { title: 'Test One!!' }, - migrationVersion: { foo: '2.3.4' }, - references: [{ name: 'search_0', type: 'search', id: '123' }], - }, - { create: { _index: '.kibana-test', _id: 'index-pattern:two' } }, + it(`returns error when there is a conflict with an existing multi-namespace saved object (get)`, async () => { + const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE }; + const response1 = { + status: 200, + docs: [ { - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { title: 'Test Two!!' }, - migrationVersion: { foo: '2.3.4' }, - references: [{ name: 'search_0', type: 'search', id: '123' }], + found: true, + _source: { + type: obj.type, + namespaces: ['bar-namespace'], + }, }, ], - }) - ); - - expect(bulkCreateResp).toEqual({ - saved_objects: [ - { - id: 'one', - type: 'config', - version: mockVersion, - updated_at: mockTimestamp, - attributes: { - title: 'Test One!!', - }, - references: [{ name: 'search_0', type: 'search', id: '123' }], - }, - { - id: 'two', - type: 'index-pattern', - version: mockVersion, - updated_at: mockTimestamp, - attributes: { - title: 'Test Two!!', - }, - references: [{ name: 'search_0', type: 'search', id: '123' }], - }, - ], + }; + callAdminCluster.mockResolvedValueOnce(response1); // this._callCluster('mget', ...) + const response2 = getMockBulkCreateResponse([obj1, obj2]); + callAdminCluster.mockResolvedValue(response2); // this._writeToCluster('bulk', ...) + + const options = { overwrite: true }; + const result = await savedObjectsRepository.bulkCreate([obj1, obj, obj2], options); + expectClusterCalls('mget', 'bulk'); + const body1 = { docs: [expect.objectContaining({ _id: `${obj.type}:${obj.id}` })] }; + expectClusterCallArgs({ body: body1 }, 1); + const body2 = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; + expectClusterCallArgs({ body: body2 }, 2); + expect(result).toEqual({ + saved_objects: [expectSuccess(obj1), expectErrorConflict(obj), expectSuccess(obj2)], + }); }); - }); - it('should overwrite objects if overwrite is truthy', async () => { - callAdminCluster.mockReturnValue({ - items: [{ create: { type: 'foo', id: 'bar', _primary_term: 1, _seq_no: 1 } }], + it(`returns error when there is a version conflict (bulk)`, async () => { + const esError = { type: 'version_conflict_engine_exception' }; + await bulkCreateError(obj3, esError, expectErrorConflict(obj3)); }); - await savedObjectsRepository.bulkCreate([{ type: 'foo', id: 'bar', attributes: {} }], { - overwrite: false, + it(`returns error when document is missing`, async () => { + const esError = { type: 'document_missing_exception' }; + await bulkCreateError(obj3, esError, expectErrorNotFound(obj3)); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - 'bulk', - expect.objectContaining({ - body: [ - // uses create because overwriting is not allowed - { create: { _index: '.kibana-test', _id: 'foo:bar' } }, - { type: 'foo', ...mockTimestampFields, foo: {}, references: [] }, - ], - }) - ); - - callAdminCluster.mockReset(); - callAdminCluster.mockReturnValue({ - items: [{ create: { type: 'foo', id: 'bar', _primary_term: 1, _seq_no: 1 } }], + it(`returns error reason for other errors`, async () => { + const esError = { reason: 'some_other_error' }; + await bulkCreateError(obj3, esError, expectErrorResult(obj3, { message: esError.reason })); }); - await savedObjectsRepository.bulkCreate([{ type: 'foo', id: 'bar', attributes: {} }], { - overwrite: true, + it(`returns error string for other errors if no reason is defined`, async () => { + const esError = { foo: 'some_other_error' }; + const expectedError = expectErrorResult(obj3, { message: JSON.stringify(esError) }); + await bulkCreateError(obj3, esError, expectedError); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - 'bulk', - expect.objectContaining({ - body: [ - // uses index because overwriting is allowed - { index: { _index: '.kibana-test', _id: 'foo:bar' } }, - { type: 'foo', ...mockTimestampFields, foo: {}, references: [] }, - ], - }) - ); }); - it('mockReturnValue document errors', async () => { - callAdminCluster.mockResolvedValue({ - errors: false, - items: [ - { - create: { - _id: 'config:one', - error: { - reason: 'type[config] missing', - }, - }, - }, - { - create: { - _id: 'index-pattern:two', - ...mockVersionProps, - }, - }, - ], + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + migrator.runMigrations = jest.fn(async () => + expect(callAdminCluster).not.toHaveBeenCalled() + ); + await expect(bulkCreateSuccess([obj1, obj2])).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveBeenCalledTimes(1); }); - const response = await savedObjectsRepository.bulkCreate([ - { type: 'config', id: 'one', attributes: { title: 'Test One' } }, - { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }, - ]); + it(`migrates the docs and serializes the migrated docs`, async () => { + migrator.migrateDocument.mockImplementation(mockMigrateDocument); + await bulkCreateSuccess([obj1, obj2]); + const docs = [obj1, obj2].map(x => ({ ...x, ...mockTimestampFields })); + expectMigrationArgs(docs[0], true, 1); + expectMigrationArgs(docs[1], true, 2); - expect(response).toEqual({ - saved_objects: [ - { - id: 'one', - type: 'config', - error: { message: 'type[config] missing' }, - }, - { - id: 'two', - type: 'index-pattern', - version: mockVersion, - ...mockTimestampFields, - attributes: { title: 'Test Two' }, - references: [], - }, - ], + const migratedDocs = docs.map(x => migrator.migrateDocument(x)); + expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(1, migratedDocs[0]); + expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(2, migratedDocs[1]); }); - }); - it('formats Elasticsearch response', async () => { - callAdminCluster.mockResolvedValue({ - errors: false, - items: [ - { - create: { - _id: 'config:one', - ...mockVersionProps, - }, - }, - { - create: { - _id: 'index-pattern:two', - ...mockVersionProps, - }, - }, - ], + it(`adds namespace to body when providing namespace for single-namespace type`, async () => { + await bulkCreateSuccess([obj1, obj2], { namespace }); + expectMigrationArgs({ namespace }, true, 1); + expectMigrationArgs({ namespace }, true, 2); }); - const response = await savedObjectsRepository.bulkCreate( - [ - { type: 'config', id: 'one', attributes: { title: 'Test One' } }, - { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }, - ], - { - namespace: 'foo-namespace', - } - ); + it(`doesn't add namespace to body when providing no namespace for single-namespace type`, async () => { + await bulkCreateSuccess([obj1, obj2]); + expectMigrationArgs({ namespace: expect.anything() }, false, 1); + expectMigrationArgs({ namespace: expect.anything() }, false, 2); + }); - expect(response).toEqual({ - saved_objects: [ - { - id: 'one', - type: 'config', - version: mockVersion, - ...mockTimestampFields, - attributes: { title: 'Test One' }, - references: [], - }, - { - id: 'two', - type: 'index-pattern', - version: mockVersion, - ...mockTimestampFields, - attributes: { title: 'Test Two' }, - references: [], - }, - ], + it(`doesn't add namespace to body when not using single-namespace type`, async () => { + const objects = [ + { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_TYPE }, + ]; + await bulkCreateSuccess(objects, { namespace }); + expectMigrationArgs({ namespace: expect.anything() }, false, 1); + expectMigrationArgs({ namespace: expect.anything() }, false, 2); }); - }); - it('prepends namespace to the id and adds namespace to body when providing namespace for namespaced type', async () => { - callAdminCluster.mockReturnValue({ - items: [ - { - create: { - _id: 'foo-namespace:config:one', - _index: '.kibana-test', - _primary_term: 1, - _seq_no: 2, - }, - }, - { - create: { - _id: 'foo-namespace:index-pattern:two', - _primary_term: 1, - _seq_no: 2, - }, - }, - ], + it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { + const objects = [obj1, obj2].map(obj => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + await bulkCreateSuccess(objects, { namespace }); + expectMigrationArgs({ namespaces: [namespace] }, true, 1); + expectMigrationArgs({ namespaces: [namespace] }, true, 2); }); - await savedObjectsRepository.bulkCreate( - [ - { type: 'config', id: 'one', attributes: { title: 'Test One' } }, - { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }, - ], - { - namespace: 'foo-namespace', - } - ); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - 'bulk', - expect.objectContaining({ - body: [ - { create: { _index: '.kibana-test', _id: 'foo-namespace:config:one' } }, - { - namespace: 'foo-namespace', - type: 'config', - ...mockTimestampFields, - config: { title: 'Test One' }, - references: [], - }, - { create: { _index: '.kibana-test', _id: 'foo-namespace:index-pattern:two' } }, - { - namespace: 'foo-namespace', - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { title: 'Test Two' }, - references: [], - }, - ], - }) - ); - }); - it(`doesn't prepend namespace to the id or add namespace property when providing no namespace for namespaced type`, async () => { - callAdminCluster.mockResolvedValue({ - errors: false, - items: [ - { - create: { - _id: 'config:one', - ...mockVersionProps, - }, - }, - { - create: { - _id: 'index-pattern:two', - ...mockVersionProps, - }, - }, - ], + it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { + const objects = [obj1, obj2].map(obj => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + await bulkCreateSuccess(objects); + expectMigrationArgs({ namespaces: ['default'] }, true, 1); + expectMigrationArgs({ namespaces: ['default'] }, true, 2); }); - await savedObjectsRepository.bulkCreate([ - { type: 'config', id: 'one', attributes: { title: 'Test One' } }, - { type: 'index-pattern', id: 'two', attributes: { title: 'Test Two' } }, - ]); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - 'bulk', - expect.objectContaining({ - body: [ - { create: { _id: 'config:one', _index: '.kibana-test' } }, - { - type: 'config', - ...mockTimestampFields, - config: { title: 'Test One' }, - references: [], - }, - { create: { _id: 'index-pattern:two', _index: '.kibana-test' } }, - { - type: 'index-pattern', - ...mockTimestampFields, - 'index-pattern': { title: 'Test Two' }, - references: [], - }, - ], - }) - ); - }); - it(`doesn't prepend namespace to the id or add namespace property when providing namespace for namespace agnostic type`, async () => { - callAdminCluster.mockReturnValue({ - items: [{ create: { _type: '_doc', _id: 'globaltype:one', _primary_term: 1, _seq_no: 2 } }], + it(`doesn't add namespaces to body when not using multi-namespace type`, async () => { + const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; + await bulkCreateSuccess(objects); + expectMigrationArgs({ namespaces: expect.anything() }, false, 1); + expectMigrationArgs({ namespaces: expect.anything() }, false, 2); }); - await savedObjectsRepository.bulkCreate( - [{ type: 'globaltype', id: 'one', attributes: { title: 'Test One' } }], - { - namespace: 'foo-namespace', - } - ); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - 'bulk', - expect.objectContaining({ - body: [ - { create: { _id: 'globaltype:one', _index: '.kibana-test' } }, - { - type: 'globaltype', - ...mockTimestampFields, - globaltype: { title: 'Test One' }, - references: [], - }, - ], - }) - ); }); - it('should return objects in the same order regardless of type', () => {}); - }); + describe('returns', () => { + it(`formats the ES response`, async () => { + const result = await bulkCreateSuccess([obj1, obj2]); + expect(result).toEqual({ + saved_objects: [obj1, obj2].map(x => expectSuccessResult(x)), + }); + }); - describe('#delete', () => { - it('waits until migrations are complete before proceeding', async () => { - migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled()); - callAdminCluster.mockReturnValue({ result: 'deleted' }); - await expect( - savedObjectsRepository.delete('index-pattern', 'logstash-*', { - namespace: 'foo-namespace', - }) - ).resolves.toBeDefined(); - - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); + it(`should return objects in the same order regardless of type`, async () => { + // TODO + }); + + it(`handles a mix of successful creates and errors`, async () => { + const obj = { + type: 'unknownType', + id: 'three', + }; + const objects = [obj1, obj, obj2]; + const response = getMockBulkCreateResponse(objects); + callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) + const result = await savedObjectsRepository.bulkCreate(objects); + expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + saved_objects: [expectSuccessResult(obj1), expectError(obj), expectSuccessResult(obj2)], + }); + }); }); + }); - it('throws notFound when ES is unable to find the document', async () => { - expect.assertions(1); + describe('#bulkGet', () => { + const obj1 = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Testing' }, + references: [ + { + name: 'ref_0', + type: 'test', + id: '1', + }, + ], + }; + const obj2 = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Testing' }, + references: [ + { + name: 'ref_0', + type: 'test', + id: '2', + }, + ], + }; + const namespace = 'foo-namespace'; - callAdminCluster.mockResolvedValue({ result: 'not_found' }); + const bulkGet = async (objects, options) => + savedObjectsRepository.bulkGet( + objects.map(({ type, id }) => ({ type, id })), // bulkGet only uses type and id + options + ); + const bulkGetSuccess = async (objects, options) => { + const response = getMockMgetResponse(objects, options?.namespace); + callAdminCluster.mockReturnValue(response); + const result = await bulkGet(objects, options); + expect(callAdminCluster).toHaveBeenCalledTimes(1); + return result; + }; - try { - await savedObjectsRepository.delete('index-pattern', 'logstash-*'); - } catch (e) { - expect(e.output.statusCode).toEqual(404); - } - }); + const _expectClusterCallArgs = ( + objects, + { _index = expect.any(String), getId = () => expect.any(String) } + ) => { + expectClusterCallArgs({ + body: { + docs: objects.map(({ type, id }) => + expect.objectContaining({ + _index, + _id: getId(type, id), + }) + ), + }, + }); + }; - it(`prepends namespace to the id when providing namespace for namespaced type`, async () => { - callAdminCluster.mockReturnValue({ result: 'deleted' }); - await savedObjectsRepository.delete('index-pattern', 'logstash-*', { - namespace: 'foo-namespace', + describe('cluster calls', () => { + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + const getId = (type, id) => `${namespace}:${type}:${id}`; + await bulkGetSuccess([obj1, obj2], { namespace }); + _expectClusterCallArgs([obj1, obj2], { getId }); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith('delete', { - id: 'foo-namespace:index-pattern:logstash-*', - refresh: 'wait_for', - index: '.kibana-test', - ignore: [404], + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + const getId = (type, id) => `${type}:${id}`; + await bulkGetSuccess([obj1, obj2]); + _expectClusterCallArgs([obj1, obj2], { getId }); }); - }); - it(`doesn't prepend namespace to the id when providing no namespace for namespaced type`, async () => { - callAdminCluster.mockReturnValue({ result: 'deleted' }); - await savedObjectsRepository.delete('index-pattern', 'logstash-*'); + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + const getId = (type, id) => `${type}:${id}`; + let objects = [obj1, obj2].map(obj => ({ ...obj, type: NAMESPACE_AGNOSTIC_TYPE })); + await bulkGetSuccess(objects, { namespace }); + _expectClusterCallArgs(objects, { getId }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith('delete', { - id: 'index-pattern:logstash-*', - refresh: 'wait_for', - index: '.kibana-test', - ignore: [404], + callAdminCluster.mockReset(); + objects = [obj1, obj2].map(obj => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + await bulkGetSuccess(objects, { namespace }); + _expectClusterCallArgs(objects, { getId }); }); }); - it(`doesn't prepend namespace to the id when providing namespace for namespace agnostic type`, async () => { - callAdminCluster.mockReturnValue({ result: 'deleted' }); - await savedObjectsRepository.delete('globaltype', 'logstash-*', { - namespace: 'foo-namespace', - }); + describe('errors', () => { + const bulkGetErrorInvalidType = async ([obj1, obj, obj2]) => { + const response = getMockMgetResponse([obj1, obj2]); + callAdminCluster.mockResolvedValue(response); + const result = await bulkGet([obj1, obj, obj2]); + expectClusterCalls('mget'); + expect(result).toEqual({ + saved_objects: [expectSuccess(obj1), expectErrorInvalidType(obj), expectSuccess(obj2)], + }); + }; - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith('delete', { - id: 'globaltype:logstash-*', - refresh: 'wait_for', - index: '.kibana-test', - ignore: [404], - }); - }); + const bulkGetErrorNotFound = async ([obj1, obj, obj2], options, response) => { + callAdminCluster.mockResolvedValue(response); + const result = await bulkGet([obj1, obj, obj2], options); + expectClusterCalls('mget'); + expect(result).toEqual({ + saved_objects: [expectSuccess(obj1), expectErrorNotFound(obj), expectSuccess(obj2)], + }); + }; - it('defaults to a refresh setting of `wait_for`', async () => { - callAdminCluster.mockReturnValue({ result: 'deleted' }); - await savedObjectsRepository.delete('globaltype', 'logstash-*'); + it(`returns error when type is invalid`, async () => { + const obj = { type: 'unknownType', id: 'three' }; + await bulkGetErrorInvalidType([obj1, obj, obj2]); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: 'wait_for', + it(`returns error when type is hidden`, async () => { + const obj = { type: HIDDEN_TYPE, id: 'three' }; + await bulkGetErrorInvalidType([obj1, obj, obj2]); }); - }); - it(`accepts a custom refresh setting`, async () => { - callAdminCluster.mockReturnValue({ result: 'deleted' }); - await savedObjectsRepository.delete('globaltype', 'logstash-*', { - refresh: false, + it(`returns error when document is not found`, async () => { + const obj = { type: 'dashboard', id: 'three', found: false }; + const response = getMockMgetResponse([obj1, obj, obj2]); + await bulkGetErrorNotFound([obj1, obj, obj2], undefined, response); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: false, + it(`handles missing ids gracefully`, async () => { + const obj = { type: 'dashboard', id: undefined, found: false }; + const response = getMockMgetResponse([obj1, obj, obj2]); + await bulkGetErrorNotFound([obj1, obj, obj2], undefined, response); }); - }); - }); - describe('#deleteByNamespace', () => { - it('requires namespace to be defined', async () => { - callAdminCluster.mockReturnValue(deleteByQueryResults); - expect(savedObjectsRepository.deleteByNamespace()).rejects.toThrowErrorMatchingSnapshot(); - expect(callAdminCluster).not.toHaveBeenCalled(); + it(`returns error when type is multi-namespace and the document exists, but not in this namespace`, async () => { + const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const response = getMockMgetResponse([obj1, obj, obj2]); + response.docs[1].namespaces = ['bar-namespace']; + await bulkGetErrorNotFound([obj1, obj, obj2], { namespace }, response); + }); }); - it('requires namespace to be a string', async () => { - callAdminCluster.mockReturnValue(deleteByQueryResults); - expect( - savedObjectsRepository.deleteByNamespace(['namespace-1', 'namespace-2']) - ).rejects.toThrowErrorMatchingSnapshot(); - expect(callAdminCluster).not.toHaveBeenCalled(); + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + migrator.runMigrations = jest.fn(async () => + expect(callAdminCluster).not.toHaveBeenCalled() + ); + await expect(bulkGetSuccess([obj1, obj2])).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveBeenCalledTimes(1); + }); }); - it('constructs a deleteByQuery call using all types that are namespace aware', async () => { - callAdminCluster.mockReturnValue(deleteByQueryResults); - const result = await savedObjectsRepository.deleteByNamespace('my-namespace'); - - expect(result).toEqual(deleteByQueryResults); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - - expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, typeRegistry, { - namespace: 'my-namespace', - type: ['config', 'baz', 'index-pattern', 'dashboard'], + describe('returns', () => { + const expectSuccessResult = ({ type, id }, doc) => ({ + type, + id, + ...(doc._source.namespaces && { namespaces: doc._source.namespaces }), + ...(doc._source.updated_at && { updated_at: doc._source.updated_at }), + version: encodeHitVersion(doc), + attributes: doc._source[type], + references: doc._source.references || [], + migrationVersion: doc._source.migrationVersion, }); - expect(callAdminCluster).toHaveBeenCalledWith('deleteByQuery', { - body: { conflicts: 'proceed' }, - ignore: [404], - index: ['.kibana-test', 'beats'], - refresh: 'wait_for', + it(`returns early for empty objects argument`, async () => { + const result = await bulkGet([]); + expect(result).toEqual({ saved_objects: [] }); + expect(callAdminCluster).not.toHaveBeenCalled(); }); - }); - - it('defaults to a refresh setting of `wait_for`', async () => { - callAdminCluster.mockReturnValue(deleteByQueryResults); - await savedObjectsRepository.deleteByNamespace('my-namespace'); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: 'wait_for', + it(`formats the ES response`, async () => { + const response = getMockMgetResponse([obj1, obj2]); + callAdminCluster.mockResolvedValue(response); + const result = await bulkGet([obj1, obj2]); + expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + saved_objects: [ + expectSuccessResult(obj1, response.docs[0]), + expectSuccessResult(obj2, response.docs[1]), + ], + }); }); - }); - it('accepts a custom refresh setting', async () => { - callAdminCluster.mockReturnValue(deleteByQueryResults); - await savedObjectsRepository.deleteByNamespace('my-namespace', { refresh: true }); + it(`handles a mix of successful gets and errors`, async () => { + const response = getMockMgetResponse([obj1, obj2]); + callAdminCluster.mockResolvedValue(response); + const obj = { type: 'unknownType', id: 'three' }; + const result = await bulkGet([obj1, obj, obj2]); + expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + saved_objects: [ + expectSuccessResult(obj1, response.docs[0]), + expectError(obj), + expectSuccessResult(obj2, response.docs[1]), + ], + }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: true, + it(`includes namespaces property for multi-namespace documents`, async () => { + const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const result = await bulkGetSuccess([obj1, obj]); + expect(result).toEqual({ + saved_objects: [ + expect.not.objectContaining({ namespaces: expect.anything() }), + expect.objectContaining({ namespaces: expect.any(Array) }), + ], + }); }); }); }); - describe('#find', () => { - it('waits until migrations are complete before proceeding', async () => { - migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled()); - - callAdminCluster.mockReturnValue(noNamespaceSearchResults); - await expect(savedObjectsRepository.find({ type: 'foo' })).resolves.toBeDefined(); - - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); - - it('requires type to be defined', async () => { - await expect(savedObjectsRepository.find({})).rejects.toThrow(/options\.type must be/); - expect(callAdminCluster).not.toHaveBeenCalled(); + describe('#bulkUpdate', () => { + const obj1 = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + }; + const obj2 = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Test Two' }, + }; + const references = [{ name: 'ref_0', type: 'test', id: '1' }]; + const namespace = 'foo-namespace'; + + const getMockBulkUpdateResponse = (objects, options) => ({ + items: objects.map(({ type, id }) => ({ + update: { + _id: `${ + registry.isSingleNamespace(type) && options?.namespace ? `${options?.namespace}:` : '' + }${type}:${id}`, + ...mockVersionProps, + result: 'updated', + }, + })), }); - it('requires searchFields be an array if defined', async () => { - callAdminCluster.mockReturnValue(noNamespaceSearchResults); - try { - await savedObjectsRepository.find({ type: 'foo', searchFields: 'string' }); - throw new Error('expected find() to reject'); - } catch (error) { - expect(callAdminCluster).not.toHaveBeenCalled(); - expect(error.message).toMatch('must be an array'); + const bulkUpdateSuccess = async (objects, options) => { + const multiNamespaceObjects = objects.filter(({ type }) => registry.isMultiNamespace(type)); + if (multiNamespaceObjects?.length) { + const response = getMockMgetResponse(multiNamespaceObjects, options?.namespace); + callAdminCluster.mockResolvedValueOnce(response); // this._callCluster('mget', ...) } - }); + const response = getMockBulkUpdateResponse(objects, options?.namespace); + callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) + const result = await savedObjectsRepository.bulkUpdate(objects, options); + expect(callAdminCluster).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 2 : 1); + return result; + }; - it('requires fields be an array if defined', async () => { - callAdminCluster.mockReturnValue(noNamespaceSearchResults); - try { - await savedObjectsRepository.find({ type: 'foo', fields: 'string' }); - throw new Error('expected find() to reject'); - } catch (error) { - expect(callAdminCluster).not.toHaveBeenCalled(); - expect(error.message).toMatch('must be an array'); + // bulk create calls have two objects for each source -- the action, and the source + const expectClusterCallArgsAction = ( + objects, + { method, _index = expect.any(String), getId = () => expect.any(String), overrides }, + n + ) => { + const body = []; + for (const { type, id } of objects) { + body.push({ + [method]: { + _index, + _id: getId(type, id), + ...overrides, + }, + }); + body.push(expect.any(Object)); } - }); - - it('passes mappings, schema, search, defaultSearchOperator, searchFields, type, sortField, sortOrder and hasReference to getSearchDsl', async () => { - callAdminCluster.mockReturnValue(namespacedSearchResults); - const relevantOpts = { - namespace: 'foo-namespace', - search: 'foo*', - searchFields: ['foo'], - type: ['bar'], - sortField: 'name', - sortOrder: 'desc', - defaultSearchOperator: 'AND', - hasReference: { - type: 'foo', - id: '1', - }, - kueryNode: undefined, - }; + expectClusterCallArgs({ body }, n); + }; - await savedObjectsRepository.find(relevantOpts); - expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(1); - expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith( - mappings, - typeRegistry, - relevantOpts - ); - }); + const expectObjArgs = ({ type, attributes }) => [ + expect.any(Object), + { + doc: expect.objectContaining({ + [type]: attributes, + ...mockTimestampFields, + }), + }, + ]; - it('accepts KQL filter and passes keuryNode to getSearchDsl', async () => { - callAdminCluster.mockReturnValue(namespacedSearchResults); - const findOpts = { - namespace: 'foo-namespace', - search: 'foo*', - searchFields: ['foo'], - type: ['dashboard'], - sortField: 'name', - sortOrder: 'desc', - defaultSearchOperator: 'AND', - hasReference: { - type: 'foo', - id: '1', - }, - indexPattern: undefined, - filter: 'dashboard.attributes.otherField: *', + describe('cluster calls', () => { + it(`should use the ES bulk action by default`, async () => { + await bulkUpdateSuccess([obj1, obj2]); + expectClusterCalls('bulk'); + }); + + it(`should use the ES mget action before bulk action for any types that are multi-namespace`, async () => { + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; + await bulkUpdateSuccess(objects); + expectClusterCalls('mget', 'bulk'); + const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; + expectClusterCallArgs({ body: { docs } }, 1); + }); + + it(`formats the ES request`, async () => { + await bulkUpdateSuccess([obj1, obj2]); + const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; + expectClusterCallArgs({ body }); + }); + + it(`formats the ES request for any types that are multi-namespace`, async () => { + const _obj2 = { ...obj2, type: MULTI_NAMESPACE_TYPE }; + await bulkUpdateSuccess([obj1, _obj2]); + const body = [...expectObjArgs(obj1), ...expectObjArgs(_obj2)]; + expectClusterCallArgs({ body }, 2); + }); + + it(`doesnt call Elasticsearch if there are no valid objects to update`, async () => { + const objects = [obj1, obj2].map(x => ({ ...x, type: 'unknownType' })); + await savedObjectsRepository.bulkUpdate(objects); + expect(callAdminCluster).toHaveBeenCalledTimes(0); + }); + + it(`defaults to no references`, async () => { + await bulkUpdateSuccess([obj1, obj2]); + const expected = { doc: expect.not.objectContaining({ references: expect.anything() }) }; + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expectClusterCallArgs({ body }); + }); + + it(`accepts custom references array`, async () => { + const test = async references => { + const objects = [obj1, obj2].map(obj => ({ ...obj, references })); + await bulkUpdateSuccess(objects); + const expected = { doc: expect.objectContaining({ references }) }; + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expectClusterCallArgs({ body }); + callAdminCluster.mockReset(); + }; + await test(references); + await test(['string']); + await test([]); + }); + + it(`doesn't accept custom references if not an array`, async () => { + const test = async references => { + const objects = [obj1, obj2].map(obj => ({ ...obj, references })); + await bulkUpdateSuccess(objects); + const expected = { doc: expect.not.objectContaining({ references: expect.anything() }) }; + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expectClusterCallArgs({ body }); + callAdminCluster.mockReset(); + }; + await test('string'); + await test(123); + await test(true); + await test(null); + }); + + it(`defaults to a refresh setting of wait_for`, async () => { + await bulkUpdateSuccess([obj1, obj2]); + expectClusterCallArgs({ refresh: 'wait_for' }); + }); + + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + await bulkUpdateSuccess([obj1, obj2], { refresh }); + expectClusterCallArgs({ refresh }); + }); + + it(`defaults to the version of the existing document for multi-namespace types`, async () => { + // only multi-namespace documents are obtained using a pre-flight mget request + const objects = [ + { ...obj1, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_TYPE }, + ]; + await bulkUpdateSuccess(objects); + const overrides = { + if_seq_no: mockVersionProps._seq_no, + if_primary_term: mockVersionProps._primary_term, + }; + expectClusterCallArgsAction(objects, { method: 'update', overrides }, 2); + }); + + it(`defaults to no version for types that are not multi-namespace`, async () => { + const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; + await bulkUpdateSuccess(objects); + expectClusterCallArgsAction(objects, { method: 'update' }); + }); + + it(`accepts version`, async () => { + const version = encodeHitVersion({ _seq_no: 100, _primary_term: 200 }); + // test with both non-multi-namespace and multi-namespace types + const objects = [ + { ...obj1, version }, + { ...obj2, type: MULTI_NAMESPACE_TYPE, version }, + ]; + await bulkUpdateSuccess(objects); + const overrides = { if_seq_no: 100, if_primary_term: 200 }; + expectClusterCallArgsAction(objects, { method: 'update', overrides }, 2); + }); + + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + const getId = (type, id) => `${namespace}:${type}:${id}`; + await bulkUpdateSuccess([obj1, obj2], { namespace }); + expectClusterCallArgsAction([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}`; + await bulkUpdateSuccess([obj1, obj2]); + expectClusterCallArgsAction([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 }); + expectClusterCallArgsAction(objects1, { method: 'update', getId }); + callAdminCluster.mockReset(); + 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 }); + expectClusterCallArgsAction(objects2, { method: 'update', getId, overrides }, 2); + }); + }); + + describe('errors', () => { + const obj = { + type: 'dashboard', + id: 'three', }; - await savedObjectsRepository.find(findOpts); - expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(1); - const { kueryNode } = getSearchDslNS.getSearchDsl.mock.calls[0][2]; - expect(kueryNode).toMatchInlineSnapshot(` - Object { - "arguments": Array [ - Object { - "type": "literal", - "value": "dashboard.otherField", - }, - Object { - "type": "wildcard", - "value": "@kuery-wildcard@", - }, - Object { - "type": "literal", - "value": false, - }, - ], - "function": "is", - "type": "function", + const bulkUpdateError = async (obj, esError, expectedError) => { + const objects = [obj1, obj, obj2]; + const mockResponse = getMockBulkUpdateResponse(objects); + if (esError) { + mockResponse.items[1].update = { error: esError }; } - `); - }); + callAdminCluster.mockResolvedValue(mockResponse); // this._writeToCluster('bulk', ...) + + const result = await savedObjectsRepository.bulkUpdate(objects); + expectClusterCalls('bulk'); + const objCall = esError ? expectObjArgs(obj) : []; + const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; + expectClusterCallArgs({ body }); + expect(result).toEqual({ + saved_objects: [expectSuccess(obj1), expectedError, expectSuccess(obj2)], + }); + }; - it('KQL filter syntax errors rejects with bad request', async () => { - callAdminCluster.mockReturnValue(namespacedSearchResults); - const findOpts = { - namespace: 'foo-namespace', - search: 'foo*', - searchFields: ['foo'], - type: ['dashboard'], - sortField: 'name', - sortOrder: 'desc', - defaultSearchOperator: 'AND', - hasReference: { - type: 'foo', - id: '1', - }, - indexPattern: undefined, - filter: 'dashboard.attributes.otherField:<', + const bulkUpdateMultiError = async ([obj1, _obj, obj2], options, mgetResponse) => { + callAdminCluster.mockResolvedValueOnce(mgetResponse); // this._callCluster('mget', ...) + const bulkResponse = getMockBulkUpdateResponse([obj1, obj2], namespace); + callAdminCluster.mockResolvedValue(bulkResponse); // this._writeToCluster('bulk', ...) + + const result = await savedObjectsRepository.bulkUpdate([obj1, _obj, obj2], options); + expectClusterCalls('mget', 'bulk'); + const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; + expectClusterCallArgs({ body }, 2); + expect(result).toEqual({ + saved_objects: [expectSuccess(obj1), expectErrorNotFound(_obj), expectSuccess(obj2)], + }); }; - await expect(savedObjectsRepository.find(findOpts)).rejects.toMatchInlineSnapshot(` - [Error: KQLSyntaxError: Expected "(", "{", value, whitespace but "<" found. - dashboard.attributes.otherField:< - --------------------------------^: Bad Request] - `); - expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(0); - }); + it(`returns error when type is invalid`, async () => { + const _obj = { ...obj, type: 'unknownType' }; + await bulkUpdateError(_obj, undefined, expectErrorNotFound(_obj)); + }); - it('merges output of getSearchDsl into es request body', async () => { - callAdminCluster.mockReturnValue(noNamespaceSearchResults); - getSearchDslNS.getSearchDsl.mockReturnValue({ query: 1, aggregations: 2 }); - await savedObjectsRepository.find({ type: 'foo' }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - 'search', - expect.objectContaining({ - body: expect.objectContaining({ - query: 1, - aggregations: 2, - }), - }) - ); - }); + it(`returns error when type is hidden`, async () => { + const _obj = { ...obj, type: HIDDEN_TYPE }; + await bulkUpdateError(_obj, undefined, expectErrorNotFound(_obj)); + }); - it('formats Elasticsearch response when there is no namespace', async () => { - callAdminCluster.mockReturnValue(noNamespaceSearchResults); - const count = noNamespaceSearchResults.hits.hits.length; + it(`returns error when ES is unable to find the document (mget)`, async () => { + const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE, found: false }; + const mgetResponse = getMockMgetResponse([_obj]); + await bulkUpdateMultiError([obj1, _obj, obj2], undefined, mgetResponse); + }); - const response = await savedObjectsRepository.find({ type: 'foo' }); + it(`returns error when ES is unable to find the index (mget)`, async () => { + const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE }; + const mgetResponse = { status: 404 }; + await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); + }); - expect(response.total).toBe(count); - expect(response.saved_objects).toHaveLength(count); + it(`returns error when there is a conflict with an existing multi-namespace saved object (mget)`, async () => { + const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE }; + const mgetResponse = getMockMgetResponse([_obj], 'bar-namespace'); + await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); + }); - noNamespaceSearchResults.hits.hits.forEach((doc, i) => { - expect(response.saved_objects[i]).toEqual({ - id: doc._id.replace(/(index-pattern|config|globaltype)\:/, ''), - type: doc._source.type, - ...mockTimestampFields, - version: mockVersion, - attributes: doc._source[doc._source.type], - references: [], - }); + it(`returns error when there is a version conflict (bulk)`, async () => { + const esError = { type: 'version_conflict_engine_exception' }; + await bulkUpdateError(obj, esError, expectErrorConflict(obj)); }); - }); - it('formats Elasticsearch response when there is a namespace', async () => { - callAdminCluster.mockReturnValue(namespacedSearchResults); - const count = namespacedSearchResults.hits.hits.length; + it(`returns error when document is missing (bulk)`, async () => { + const esError = { type: 'document_missing_exception' }; + await bulkUpdateError(obj, esError, expectErrorNotFound(obj)); + }); - const response = await savedObjectsRepository.find({ - type: 'foo', - namespace: 'foo-namespace', + it(`returns error reason for other errors (bulk)`, async () => { + const esError = { reason: 'some_other_error' }; + await bulkUpdateError(obj, esError, expectErrorResult(obj, { message: esError.reason })); }); - expect(response.total).toBe(count); - expect(response.saved_objects).toHaveLength(count); + it(`returns error string for other errors if no reason is defined (bulk)`, async () => { + const esError = { foo: 'some_other_error' }; + const expectedError = expectErrorResult(obj, { message: JSON.stringify(esError) }); + await bulkUpdateError(obj, esError, expectedError); + }); + }); - namespacedSearchResults.hits.hits.forEach((doc, i) => { - expect(response.saved_objects[i]).toEqual({ - id: doc._id.replace(/(foo-namespace\:)?(index-pattern|config|globaltype)\:/, ''), - type: doc._source.type, - ...mockTimestampFields, - version: mockVersion, - attributes: doc._source[doc._source.type], - references: [], - }); + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + migrator.runMigrations = jest.fn(async () => + expect(callAdminCluster).not.toHaveBeenCalled() + ); + await expect(bulkUpdateSuccess([obj1, obj2])).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveReturnedTimes(1); }); }); - it('accepts per_page/page', async () => { - callAdminCluster.mockReturnValue(noNamespaceSearchResults); - await savedObjectsRepository.find({ type: 'foo', perPage: 10, page: 6 }); + describe('returns', () => { + const expectSuccessResult = ({ type, id, attributes, references }) => ({ + type, + id, + attributes, + references, + version: mockVersion, + ...mockTimestampFields, + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - size: 10, - from: 50, - }) - ); - }); + it(`formats the ES response`, async () => { + const response = await bulkUpdateSuccess([obj1, obj2]); + expect(response).toEqual({ + saved_objects: [obj1, obj2].map(expectSuccessResult), + }); + }); - it('can filter by fields', async () => { - callAdminCluster.mockReturnValue(noNamespaceSearchResults); - await savedObjectsRepository.find({ type: 'foo', fields: ['title'] }); + it(`includes references`, async () => { + const objects = [obj1, obj2].map(obj => ({ ...obj, references })); + const response = await bulkUpdateSuccess(objects); + expect(response).toEqual({ + saved_objects: objects.map(expectSuccessResult), + }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - _source: [ - 'foo.title', - 'namespace', - 'type', - 'references', - 'migrationVersion', - 'updated_at', - 'title', + it(`handles a mix of successful updates and errors`, async () => { + const obj = { + type: 'unknownType', + id: 'three', + }; + const objects = [obj1, obj, obj2]; + const mockResponse = getMockBulkUpdateResponse(objects); + callAdminCluster.mockResolvedValue(mockResponse); // this._writeToCluster('bulk', ...) + const result = await savedObjectsRepository.bulkUpdate(objects); + expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + saved_objects: [expectSuccessResult(obj1), expectError(obj), expectSuccessResult(obj2)], + }); + }); + + it(`includes namespaces property for multi-namespace documents`, async () => { + const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; + const result = await bulkUpdateSuccess([obj1, obj]); + expect(result).toEqual({ + saved_objects: [ + expect.not.objectContaining({ namespaces: expect.anything() }), + expect.objectContaining({ namespaces: expect.any(Array) }), ], - }) - ); + }); + }); }); + }); - it('should set rest_total_hits_as_int to true on a request', async () => { - callAdminCluster.mockReturnValue(noNamespaceSearchResults); - await savedObjectsRepository.find({ type: 'foo' }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toHaveProperty('rest_total_hits_as_int', true); + describe('#create', () => { + beforeEach(() => { + callAdminCluster.mockImplementation((method, params) => ({ + _id: params.id, + ...mockVersionProps, + })); }); - }); - describe('#get', () => { - const noNamespaceResult = { - _id: 'index-pattern:logstash-*', - ...mockVersionProps, - _source: { - type: 'index-pattern', - specialProperty: 'specialValue', - ...mockTimestampFields, - 'index-pattern': { - title: 'Testing', - }, - }, - }; - const namespacedResult = { - _id: 'foo-namespace:index-pattern:logstash-*', - ...mockVersionProps, - _source: { - namespace: 'foo-namespace', - type: 'index-pattern', - specialProperty: 'specialValue', - ...mockTimestampFields, - 'index-pattern': { - title: 'Testing', - }, + const type = 'index-pattern'; + const attributes = { title: 'Logstash' }; + const id = 'logstash-*'; + const namespace = 'foo-namespace'; + const references = [ + { + name: 'ref_0', + type: 'test', + id: '123', }, - }; + ]; - it('waits until migrations are complete before proceeding', async () => { - migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled()); + const createSuccess = async (type, attributes, options) => { + const result = await savedObjectsRepository.create(type, attributes, options); + expect(callAdminCluster).toHaveBeenCalledTimes( + registry.isMultiNamespace(type) && options.overwrite ? 2 : 1 + ); + return result; + }; - callAdminCluster.mockResolvedValue(noNamespaceResult); - await expect( - savedObjectsRepository.get('index-pattern', 'logstash-*') - ).resolves.toBeDefined(); + describe('cluster calls', () => { + it(`should use the ES create action if ID is undefined and overwrite=true`, async () => { + await createSuccess(type, attributes, { overwrite: true }); + expectClusterCalls('create'); + }); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); + it(`should use the ES create action if ID is undefined and overwrite=false`, async () => { + await createSuccess(type, attributes); + expectClusterCalls('create'); + }); - it('formats Elasticsearch response when there is no namespace', async () => { - callAdminCluster.mockResolvedValue(noNamespaceResult); - const response = await savedObjectsRepository.get('index-pattern', 'logstash-*'); - expect(response).toEqual({ - id: 'logstash-*', - type: 'index-pattern', - updated_at: mockTimestamp, - version: mockVersion, - attributes: { - title: 'Testing', - }, - references: [], + it(`should use the ES index action if ID is defined and overwrite=true`, async () => { + await createSuccess(type, attributes, { id, overwrite: true }); + expectClusterCalls('index'); }); - }); - it('formats Elasticsearch response when there are namespaces', async () => { - callAdminCluster.mockResolvedValue(namespacedResult); - const response = await savedObjectsRepository.get('index-pattern', 'logstash-*'); - expect(response).toEqual({ - id: 'logstash-*', - type: 'index-pattern', - updated_at: mockTimestamp, - version: mockVersion, - attributes: { - title: 'Testing', - }, - references: [], + it(`should use the ES create action if ID is defined and overwrite=false`, async () => { + await createSuccess(type, attributes, { id }); + expectClusterCalls('create'); }); - }); - it('prepends namespace and type to the id when providing namespace for namespaced type', async () => { - callAdminCluster.mockResolvedValue(namespacedResult); - await savedObjectsRepository.get('index-pattern', 'logstash-*', { - namespace: 'foo-namespace', + it(`should use the ES get action then index action if type is multi-namespace, ID is defined, and overwrite=true`, async () => { + await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, overwrite: true }); + expectClusterCalls('get', 'index'); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - id: 'foo-namespace:index-pattern:logstash-*', - }) - ); - }); + it(`defaults to empty references array`, async () => { + await createSuccess(type, attributes, { id }); + expectClusterCallArgs({ + body: expect.objectContaining({ references: [] }), + }); + }); - it(`only prepends type to the id when providing no namespace for namespaced type`, async () => { - callAdminCluster.mockResolvedValue(noNamespaceResult); - await savedObjectsRepository.get('index-pattern', 'logstash-*'); + it(`accepts custom references array`, async () => { + const test = async references => { + await createSuccess(type, attributes, { id, references }); + expectClusterCallArgs({ + body: expect.objectContaining({ references }), + }); + callAdminCluster.mockReset(); + }; + await test(references); + await test(['string']); + await test([]); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - id: 'index-pattern:logstash-*', - }) - ); - }); + it(`doesn't accept custom references if not an array`, async () => { + const test = async references => { + await createSuccess(type, attributes, { id, references }); + expectClusterCallArgs({ + body: expect.not.objectContaining({ references: expect.anything() }), + }); + callAdminCluster.mockReset(); + }; + await test('string'); + await test(123); + await test(true); + await test(null); + }); - it(`doesn't prepend namespace to the id when providing namespace for namespace agnostic type`, async () => { - callAdminCluster.mockResolvedValue(namespacedResult); - await savedObjectsRepository.get('globaltype', 'logstash-*', { - namespace: 'foo-namespace', + it(`defaults to a refresh setting of wait_for`, async () => { + await createSuccess(type, attributes); + expectClusterCallArgs({ refresh: 'wait_for' }); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - id: 'globaltype:logstash-*', - }) - ); - }); - }); + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + await createSuccess(type, attributes, { refresh }); + expectClusterCallArgs({ refresh }); + }); - describe('#bulkGet', () => { - it('waits until migrations are complete before proceeding', async () => { - migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled()); - - callAdminCluster.mockReturnValue({ docs: [] }); - await expect( - savedObjectsRepository.bulkGet([ - { id: 'one', type: 'config' }, - { id: 'two', type: 'index-pattern' }, - { id: 'three', type: 'globaltype' }, - ]) - ).resolves.toBeDefined(); - - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); + it(`should use default index`, async () => { + await createSuccess(type, attributes, { id }); + expectClusterCallArgs({ index: '.kibana-test' }); + }); - it('prepends type to id when getting objects when there is no namespace', async () => { - callAdminCluster.mockReturnValue({ docs: [] }); + it(`should use custom index`, async () => { + await createSuccess(CUSTOM_INDEX_TYPE, attributes, { id }); + expectClusterCallArgs({ index: 'custom' }); + }); - await savedObjectsRepository.bulkGet([ - { id: 'one', type: 'config' }, - { id: 'two', type: 'index-pattern' }, - { id: 'three', type: 'globaltype' }, - ]); + it(`self-generates an id if none is provided`, async () => { + await createSuccess(type, attributes); + expectClusterCallArgs({ + id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/), + }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: { - docs: [ - { _id: 'config:one', _index: '.kibana-test' }, - { _id: 'index-pattern:two', _index: '.kibana-test' }, - { _id: 'globaltype:three', _index: '.kibana-test' }, - ], - }, - }) - ); - }); + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + await createSuccess(type, attributes, { id, namespace }); + expectClusterCallArgs({ id: `${namespace}:${type}:${id}` }); + }); - it('prepends namespace and type appropriately to id when getting objects when there is a namespace', async () => { - callAdminCluster.mockReturnValue({ docs: [] }); + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + await createSuccess(type, attributes, { id }); + expectClusterCallArgs({ id: `${type}:${id}` }); + }); - await savedObjectsRepository.bulkGet( - [ - { id: 'one', type: 'config' }, - { id: 'two', type: 'index-pattern' }, - { id: 'three', type: 'globaltype' }, - ], - { - namespace: 'foo-namespace', - } - ); + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + await createSuccess(NAMESPACE_AGNOSTIC_TYPE, attributes, { id, namespace }); + expectClusterCallArgs({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }); + callAdminCluster.mockReset(); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: { - docs: [ - { _id: 'foo-namespace:config:one', _index: '.kibana-test' }, - { _id: 'foo-namespace:index-pattern:two', _index: '.kibana-test' }, - { _id: 'globaltype:three', _index: '.kibana-test' }, - ], - }, - }) - ); + await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); + expectClusterCallArgs({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }); + }); }); - it('mockReturnValue early for empty objects argument', async () => { - callAdminCluster.mockReturnValue({ docs: [] }); + describe('errors', () => { + it(`throws when type is invalid`, async () => { + await expect(savedObjectsRepository.create('unknownType', attributes)).rejects.toThrowError( + createUnsupportedTypeError('unknownType') + ); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); - const response = await savedObjectsRepository.bulkGet([]); + it(`throws when type is hidden`, async () => { + await expect(savedObjectsRepository.create(HIDDEN_TYPE, attributes)).rejects.toThrowError( + createUnsupportedTypeError(HIDDEN_TYPE) + ); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); - expect(response.saved_objects).toHaveLength(0); - expect(callAdminCluster).not.toHaveBeenCalled(); + 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', + }); + callAdminCluster.mockResolvedValue(response); // this._callCluster('get', ...) + await expect( + savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { + id, + overwrite: true, + namespace, + }) + ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id)); + expectClusterCalls('get'); + }); + + it(`throws when automatic index creation fails`, async () => { + // TODO + }); + + it(`throws when an unexpected failure occurs`, async () => { + // TODO + }); }); - it('handles missing ids gracefully', async () => { - callAdminCluster.mockResolvedValue({ - docs: [ - { - _id: 'config:good', - found: true, - ...mockVersionProps, - _source: { ...mockTimestampFields, config: { title: 'Test' } }, - }, - { - _id: 'config:bad', - found: false, - }, - ], + describe('migration', () => { + beforeEach(() => { + migrator.migrateDocument.mockImplementation(mockMigrateDocument); }); - const { saved_objects: savedObjects } = await savedObjectsRepository.bulkGet([ - { id: 'good', type: 'config' }, - { type: 'config' }, - ]); + it(`waits until migrations are complete before proceeding`, async () => { + migrator.runMigrations = jest.fn(async () => + expect(callAdminCluster).not.toHaveBeenCalled() + ); + await expect(createSuccess(type, attributes, { id, namespace })).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveBeenCalledTimes(1); + }); - expect(savedObjects[1]).toEqual({ - type: 'config', - error: { statusCode: 404, message: 'Not found' }, + it(`migrates a document and serializes the migrated doc`, async () => { + const migrationVersion = mockMigrationVersion; + await createSuccess(type, attributes, { id, references, migrationVersion }); + const doc = { type, id, attributes, references, migrationVersion, ...mockTimestampFields }; + expectMigrationArgs(doc); + + const migratedDoc = migrator.migrateDocument(doc); + expect(serializer.savedObjectToRaw).toHaveBeenLastCalledWith(migratedDoc); }); - }); - it('reports error on missed objects', async () => { - callAdminCluster.mockResolvedValue({ - docs: [ - { - _id: 'config:good', - found: true, - ...mockVersionProps, - _source: { ...mockTimestampFields, config: { title: 'Test' } }, - }, - { - _id: 'config:bad', - found: false, - }, - ], + it(`adds namespace to body when providing namespace for single-namespace type`, async () => { + await createSuccess(type, attributes, { id, namespace }); + expectMigrationArgs({ namespace }); }); - const { saved_objects: savedObjects } = await savedObjectsRepository.bulkGet([ - { id: 'good', type: 'config' }, - { id: 'bad', type: 'config' }, - ]); + it(`doesn't add namespace to body when providing no namespace for single-namespace type`, async () => { + await createSuccess(type, attributes, { id }); + expectMigrationArgs({ namespace: expect.anything() }, false); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + it(`doesn't add namespace to body when not using single-namespace type`, async () => { + await createSuccess(NAMESPACE_AGNOSTIC_TYPE, attributes, { id, namespace }); + expectMigrationArgs({ namespace: expect.anything() }, false, 1); - expect(savedObjects).toHaveLength(2); - expect(savedObjects[0]).toEqual({ - id: 'good', - type: 'config', - ...mockTimestampFields, - version: mockVersion, - attributes: { title: 'Test' }, - references: [], + callAdminCluster.mockReset(); + await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id }); + expectMigrationArgs({ namespace: expect.anything() }, false, 2); }); - expect(savedObjects[1]).toEqual({ - id: 'bad', - type: 'config', - error: { statusCode: 404, message: 'Not found' }, + + it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { + await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); + expectMigrationArgs({ namespaces: [namespace] }); }); - }); - it('returns errors when requesting unsupported types', async () => { - callAdminCluster.mockResolvedValue({ - docs: [ - { - _id: 'one', - found: true, - ...mockVersionProps, - _source: { ...mockTimestampFields, config: { title: 'Test1' } }, - }, - { - _id: 'three', - found: true, - ...mockVersionProps, - _source: { ...mockTimestampFields, config: { title: 'Test3' } }, - }, - { - _id: 'five', - found: true, - ...mockVersionProps, - _source: { ...mockTimestampFields, config: { title: 'Test5' } }, - }, - ], + it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { + await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id }); + expectMigrationArgs({ namespaces: ['default'] }); }); - const { saved_objects: savedObjects } = await savedObjectsRepository.bulkGet([ - { id: 'one', type: 'config' }, - { id: 'two', type: 'invalidtype' }, - { id: 'three', type: 'config' }, - { id: 'four', type: 'invalidtype' }, - { id: 'five', type: 'config' }, - ]); + it(`doesn't add namespaces to body when not using multi-namespace type`, async () => { + await createSuccess(type, attributes, { id }); + expectMigrationArgs({ namespaces: expect.anything() }, false, 1); - expect(savedObjects).toEqual([ - { - attributes: { title: 'Test1' }, - id: 'one', - ...mockTimestampFields, - references: [], - type: 'config', - version: mockVersion, - migrationVersion: undefined, - }, - { - attributes: { title: 'Test3' }, - id: 'three', - ...mockTimestampFields, - references: [], - type: 'config', - version: mockVersion, - migrationVersion: undefined, - }, - { - attributes: { title: 'Test5' }, - id: 'five', + callAdminCluster.mockReset(); + await createSuccess(NAMESPACE_AGNOSTIC_TYPE, attributes, { id }); + expectMigrationArgs({ namespaces: expect.anything() }, false, 2); + }); + }); + + describe('returns', () => { + it(`formats the ES response`, async () => { + const result = await createSuccess(type, attributes, { id, namespace, references }); + expect(result).toEqual({ + type, + id, ...mockTimestampFields, - references: [], - type: 'config', version: mockVersion, - migrationVersion: undefined, - }, - { - error: { - error: 'Bad Request', - message: "Unsupported saved object type: 'invalidtype': Bad Request", - statusCode: 400, - }, - id: 'two', - type: 'invalidtype', - }, - { - error: { - error: 'Bad Request', - message: "Unsupported saved object type: 'invalidtype': Bad Request", - statusCode: 400, - }, - id: 'four', - type: 'invalidtype', - }, - ]); + attributes, + references, + }); + }); }); }); - describe('#update', () => { - const id = 'logstash-*'; + describe('#delete', () => { const type = 'index-pattern'; - const attributes = { title: 'Testing' }; + const id = 'logstash-*'; + const namespace = 'foo-namespace'; - beforeEach(() => { - callAdminCluster.mockResolvedValue({ - _id: `${type}:${id}`, - ...mockVersionProps, - result: 'updated', + const deleteSuccess = async (type, id, options) => { + if (registry.isMultiNamespace(type)) { + const mockGetResponse = getMockGetResponse({ type, id, namespace: options?.namespace }); + callAdminCluster.mockResolvedValueOnce(mockGetResponse); // this._callCluster('get', ...) + } + callAdminCluster.mockResolvedValue({ result: 'deleted' }); // this._writeToCluster('delete', ...) + const result = await savedObjectsRepository.delete(type, id, options); + expect(callAdminCluster).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 2 : 1); + return result; + }; + + describe('cluster calls', () => { + it(`should use the ES delete action when not using a multi-namespace type`, async () => { + await deleteSuccess(type, id); + expectClusterCalls('delete'); }); - }); - it('waits until migrations are complete before proceeding', async () => { - migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled()); + it(`should use ES get action then delete action when using a multi-namespace type with no namespaces remaining`, async () => { + await deleteSuccess(MULTI_NAMESPACE_TYPE, id); + expectClusterCalls('get', 'delete'); + }); - await expect( - savedObjectsRepository.update('index-pattern', 'logstash-*', attributes, { - namespace: 'foo-namespace', - }) - ).resolves.toBeDefined(); + it(`should use ES get action then update action when using a multi-namespace type with one or more namespaces remaining`, async () => { + const mockResponse = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }); + mockResponse._source.namespaces = ['default', 'some-other-nameespace']; + callAdminCluster + .mockResolvedValueOnce(mockResponse) // this._callCluster('get', ...) + .mockResolvedValue({ result: 'updated' }); // this._writeToCluster('update', ...) + await savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id); + expectClusterCalls('get', 'update'); + }); - expect(migrator.runMigrations).toHaveReturnedTimes(1); - }); + it(`includes the version of the existing document when type is multi-namespace`, async () => { + await deleteSuccess(MULTI_NAMESPACE_TYPE, id); + const versionProperties = { + if_seq_no: mockVersionProps._seq_no, + if_primary_term: mockVersionProps._primary_term, + }; + expectClusterCallArgs(versionProperties, 2); + }); - it('mockReturnValue current ES document _seq_no and _primary_term encoded as version', async () => { - const response = await savedObjectsRepository.update( - 'index-pattern', - 'logstash-*', - attributes, - { - namespace: 'foo-namespace', - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], - } - ); - expect(response).toEqual({ - id, - type, - ...mockTimestampFields, - version: mockVersion, - attributes, - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], + it(`defaults to a refresh setting of wait_for`, async () => { + await deleteSuccess(type, id); + expectClusterCallArgs({ refresh: 'wait_for' }); }); - }); - it('accepts version', async () => { - await savedObjectsRepository.update( - type, - id, - { title: 'Testing' }, - { - version: encodeHitVersion({ - _seq_no: 100, - _primary_term: 200, - }), - } - ); + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + await deleteSuccess(type, id, { refresh }); + expectClusterCallArgs({ refresh }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - if_seq_no: 100, - if_primary_term: 200, - }) - ); + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + await deleteSuccess(type, id, { namespace }); + expectClusterCallArgs({ id: `${namespace}:${type}:${id}` }); + }); + + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + await deleteSuccess(type, id); + expectClusterCallArgs({ id: `${type}:${id}` }); + }); + + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + await deleteSuccess(NAMESPACE_AGNOSTIC_TYPE, id, { namespace }); + expectClusterCallArgs({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }); + + callAdminCluster.mockReset(); + await deleteSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); + expectClusterCallArgs({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }); + }); }); - it('does not pass references if omitted', async () => { - await savedObjectsRepository.update(type, id, { title: 'Testing' }); + describe('errors', () => { + const expectNotFoundError = async (type, id, options) => { + await expect(savedObjectsRepository.delete(type, id, options)).rejects.toThrowError( + createGenericNotFoundError(type, id) + ); + }; + + it(`throws when type is invalid`, async () => { + await expectNotFoundError('unknownType', id); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); + + it(`throws when type is hidden`, async () => { + await expectNotFoundError(HIDDEN_TYPE, id); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); + + it(`throws when ES is unable to find the document during get`, async () => { + callAdminCluster.mockResolvedValue({ found: false }); // this._callCluster('get', ...) + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + expectClusterCalls('get'); + }); + + it(`throws when ES is unable to find the index during get`, async () => { + callAdminCluster.mockResolvedValue({ status: 404 }); // this._callCluster('get', ...) + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + expectClusterCalls('get'); + }); + + 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 }); + callAdminCluster.mockResolvedValue(response); // this._callCluster('get', ...) + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + expectClusterCalls('get'); + }); + + it(`throws when ES is unable to find the document during update`, async () => { + const mockResponse = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }); + mockResponse._source.namespaces = ['default', 'some-other-nameespace']; + callAdminCluster + .mockResolvedValueOnce(mockResponse) // this._callCluster('get', ...) + .mockResolvedValue({ status: 404 }); // this._writeToCluster('update', ...) + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + expectClusterCalls('get', 'update'); + }); + + it(`throws when ES is unable to find the document during delete`, async () => { + callAdminCluster.mockResolvedValue({ result: 'not_found' }); // this._writeToCluster('delete', ...) + await expectNotFoundError(type, id); + expectClusterCalls('delete'); + }); + + it(`throws when ES is unable to find the index during delete`, async () => { + callAdminCluster.mockResolvedValue({ error: { type: 'index_not_found_exception' } }); // this._writeToCluster('delete', ...) + await expectNotFoundError(type, id); + expectClusterCalls('delete'); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).not.toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: { - doc: expect.objectContaining({ - references: [], - }), - }, - }) - ); + it(`throws when ES returns an unexpected response`, async () => { + callAdminCluster.mockResolvedValue({ result: 'something unexpected' }); // this._writeToCluster('delete', ...) + await expect(savedObjectsRepository.delete(type, id)).rejects.toThrowError( + 'Unexpected Elasticsearch DELETE response' + ); + expectClusterCalls('delete'); + }); }); - it('passes references if they are provided', async () => { - await savedObjectsRepository.update(type, id, { title: 'Testing' }, { references: ['foo'] }); + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + let callAdminClusterCount = 0; + migrator.runMigrations = jest.fn(async () => + // runMigrations should resolve before callAdminCluster is initiated + expect(callAdminCluster).toHaveBeenCalledTimes(callAdminClusterCount++) + ); + await expect(deleteSuccess(type, id)).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveBeenCalledTimes(1); + }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: { - doc: expect.objectContaining({ - references: ['foo'], - }), - }, - }) - ); + describe('returns', () => { + it(`returns an empty object on success`, async () => { + const result = await deleteSuccess(type, id); + expect(result).toEqual({}); + }); }); + }); - it('passes empty references array if empty references array is provided', async () => { - await savedObjectsRepository.update(type, id, { title: 'Testing' }, { references: [] }); + describe('#deleteByNamespace', () => { + const namespace = 'foo-namespace'; + const mockUpdateResults = { + took: 15, + timed_out: false, + total: 3, + updated: 2, + deleted: 1, + batches: 1, + version_conflicts: 0, + noops: 0, + retries: { bulk: 0, search: 0 }, + throttled_millis: 0, + requests_per_second: -1.0, + throttled_until_millis: 0, + failures: [], + }; + const deleteByNamespaceSuccess = async (namespace, options) => { + callAdminCluster.mockResolvedValue(mockUpdateResults); + const result = await savedObjectsRepository.deleteByNamespace(namespace, options); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(1); expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - body: { - doc: expect.objectContaining({ - references: [], - }), - }, - }) - ); - }); + return result; + }; - it(`prepends namespace to the id but doesn't add namespace to body when providing namespace for namespaced type`, async () => { - await savedObjectsRepository.update( - 'index-pattern', - 'logstash-*', - { - title: 'Testing', - }, - { - namespace: 'foo-namespace', - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], - } - ); + describe('cluster calls', () => { + it(`should use the ES updateByQuery action`, async () => { + await deleteByNamespaceSuccess(namespace); + expectClusterCalls('updateByQuery'); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith('update', { - id: 'foo-namespace:index-pattern:logstash-*', - body: { - doc: { - updated_at: mockTimestamp, - 'index-pattern': { title: 'Testing' }, - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], - }, - }, - ignore: [404], - refresh: 'wait_for', - index: '.kibana-test', + it(`defaults to a refresh setting of wait_for`, async () => { + await deleteByNamespaceSuccess(namespace); + expectClusterCallArgs({ refresh: 'wait_for' }); }); - }); - it(`doesn't prepend namespace to the id or add namespace property when providing no namespace for namespaced type`, async () => { - await savedObjectsRepository.update( - 'index-pattern', - 'logstash-*', - { - title: 'Testing', - }, - { - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], - } - ); + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + await deleteByNamespaceSuccess(namespace, { refresh }); + expectClusterCallArgs({ refresh }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith('update', { - id: 'index-pattern:logstash-*', - body: { - doc: { - updated_at: mockTimestamp, - 'index-pattern': { title: 'Testing' }, - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], - }, - }, - ignore: [404], - refresh: 'wait_for', - index: '.kibana-test', + it(`should use all indices for types that are not namespace-agnostic`, async () => { + await deleteByNamespaceSuccess(namespace); + expectClusterCallArgs({ index: ['.kibana-test', 'custom'] }, 1); }); }); - it(`doesn't prepend namespace to the id or add namespace property when providing namespace for namespace agnostic type`, async () => { - await savedObjectsRepository.update( - 'globaltype', - 'foo', - { - name: 'bar', - }, - { - namespace: 'foo-namespace', - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], - } - ); - - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledWith('update', { - id: 'globaltype:foo', - body: { - doc: { - updated_at: mockTimestamp, - globaltype: { name: 'bar' }, - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], - }, - }, - ignore: [404], - refresh: 'wait_for', - index: '.kibana-test', + describe('errors', () => { + it(`throws when namespace is not a string`, async () => { + const test = async namespace => { + await expect(savedObjectsRepository.deleteByNamespace(namespace)).rejects.toThrowError( + `namespace is required, and must be a string` + ); + expect(callAdminCluster).not.toHaveBeenCalled(); + }; + await test(undefined); + await test(['namespace']); + await test(123); + await test(true); }); }); - it('defaults to a refresh setting of `wait_for`', async () => { - await savedObjectsRepository.update('globaltype', 'foo', { - name: 'bar', + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + migrator.runMigrations = jest.fn(async () => + expect(callAdminCluster).not.toHaveBeenCalled() + ); + await expect(deleteByNamespaceSuccess(namespace)).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveBeenCalledTimes(1); }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: 'wait_for', + describe('returns', () => { + it(`returns the query results on success`, async () => { + const result = await deleteByNamespaceSuccess(namespace); + expect(result).toEqual(mockUpdateResults); }); }); - it('accepts a custom refresh setting', async () => { - await savedObjectsRepository.update( - 'globaltype', - 'foo', - { - name: 'bar', - }, - { - refresh: true, - namespace: 'foo-namespace', - } - ); - - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: true, + describe('search dsl', () => { + it(`constructs a query using all multi-namespace types, and another using all single-namespace types`, async () => { + await deleteByNamespaceSuccess(namespace); + const allTypes = registry.getAllTypes().map(type => type.name); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, { + namespace, + type: allTypes.filter(type => !registry.isNamespaceAgnostic(type)), + }); }); }); }); - describe('#bulkUpdate', () => { - const { generateSavedObject, reset } = (() => { - let count = 0; + describe('#find', () => { + const generateSearchResults = namespace => { return { - generateSavedObject(overrides) { - count++; - return _.merge( + hits: { + total: 4, + hits: [ { - type: 'index-pattern', - id: `logstash-${count}`, - attributes: { title: `Testing ${count}` }, - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', + _index: '.kibana', + _id: `${namespace ? `${namespace}:` : ''}index-pattern:logstash-*`, + _score: 1, + ...mockVersionProps, + _source: { + namespace, + type: 'index-pattern', + ...mockTimestampFields, + 'index-pattern': { + title: 'logstash-*', + timeFieldName: '@timestamp', + notExpandable: true, }, - ], + }, }, - overrides - ); - }, - reset() { - count = 0; + { + _index: '.kibana', + _id: `${namespace ? `${namespace}:` : ''}config:6.0.0-alpha1`, + _score: 1, + ...mockVersionProps, + _source: { + namespace, + type: 'config', + ...mockTimestampFields, + config: { + buildNum: 8467, + defaultIndex: 'logstash-*', + }, + }, + }, + { + _index: '.kibana', + _id: `${namespace ? `${namespace}:` : ''}index-pattern:stocks-*`, + _score: 1, + ...mockVersionProps, + _source: { + namespace, + type: 'index-pattern', + ...mockTimestampFields, + 'index-pattern': { + title: 'stocks-*', + timeFieldName: '@timestamp', + notExpandable: true, + }, + }, + }, + { + _index: '.kibana', + _id: `${NAMESPACE_AGNOSTIC_TYPE}:something`, + _score: 1, + ...mockVersionProps, + _source: { + type: NAMESPACE_AGNOSTIC_TYPE, + ...mockTimestampFields, + [NAMESPACE_AGNOSTIC_TYPE]: { + name: 'bar', + }, + }, + }, + ], }, }; - })(); + }; - beforeEach(() => { - reset(); - }); + const type = 'index-pattern'; + const namespace = 'foo-namespace'; - const mockValidResponse = objects => - callAdminCluster.mockReturnValue({ - items: objects.map(items => ({ - update: { - _id: `${items.type}:${items.id}`, - ...mockVersionProps, - result: 'updated', - }, - })), + const findSuccess = async (options, namespace) => { + callAdminCluster.mockResolvedValue(generateSearchResults(namespace)); + const result = await savedObjectsRepository.find(options); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(1); + expect(callAdminCluster).toHaveBeenCalledTimes(1); + return result; + }; + + describe('cluster calls', () => { + it(`should use the ES search action`, async () => { + await findSuccess({ type }); + expectClusterCalls('search'); + }); + + it(`merges output of getSearchDsl into es request body`, async () => { + const query = { query: 1, aggregations: 2 }; + getSearchDslNS.getSearchDsl.mockReturnValue(query); + await findSuccess({ type }); + expectClusterCallArgs({ body: expect.objectContaining({ ...query }) }); }); - it('waits until migrations are complete before proceeding', async () => { - const objects = [generateSavedObject(), generateSavedObject()]; + it(`accepts per_page/page`, async () => { + await findSuccess({ type, perPage: 10, page: 6 }); + expectClusterCallArgs({ + size: 10, + from: 50, + }); + }); - migrator.runMigrations = jest.fn(async () => expect(callAdminCluster).not.toHaveBeenCalled()); + it(`can filter by fields`, async () => { + await findSuccess({ type, fields: ['title'] }); + expectClusterCallArgs({ + _source: [ + `${type}.title`, + 'namespace', + 'namespaces', + 'type', + 'references', + 'migrationVersion', + 'updated_at', + 'title', + ], + }); + }); - mockValidResponse(objects); + it(`should set rest_total_hits_as_int to true on a request`, async () => { + await findSuccess({ type }); + expectClusterCallArgs({ rest_total_hits_as_int: true }); + }); - await expect( - savedObjectsRepository.bulkUpdate([generateSavedObject()]) - ).resolves.toBeDefined(); + it(`should not make a cluster call when attempting to find only invalid or hidden types`, async () => { + const test = async types => { + await savedObjectsRepository.find({ type: types }); + expect(callAdminCluster).not.toHaveBeenCalled(); + }; - expect(migrator.runMigrations).toHaveReturnedTimes(1); + await test('unknownType'); + await test(HIDDEN_TYPE); + await test(['unknownType', HIDDEN_TYPE]); + }); }); - it('returns current ES document, _seq_no and _primary_term encoded as version', async () => { - const objects = [generateSavedObject(), generateSavedObject()]; - - mockValidResponse(objects); + describe('errors', () => { + it(`throws when type is not defined`, async () => { + await expect(savedObjectsRepository.find({})).rejects.toThrowError( + 'options.type must be a string or an array of strings' + ); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); - const response = await savedObjectsRepository.bulkUpdate(objects); + it(`throws when searchFields is defined but not an array`, async () => { + await expect( + savedObjectsRepository.find({ type, searchFields: 'string' }) + ).rejects.toThrowError('options.searchFields must be an array'); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); - expect(response.saved_objects[0]).toMatchObject({ - ..._.pick(objects[0], 'id', 'type', 'attributes'), - version: mockVersion, - references: objects[0].references, + it(`throws when fields is defined but not an array`, async () => { + await expect(savedObjectsRepository.find({ type, fields: 'string' })).rejects.toThrowError( + 'options.fields must be an array' + ); + expect(callAdminCluster).not.toHaveBeenCalled(); }); - expect(response.saved_objects[1]).toMatchObject({ - ..._.pick(objects[1], 'id', 'type', 'attributes'), - version: mockVersion, - references: objects[1].references, + + it(`throws when KQL filter syntax is invalid`, async () => { + const findOpts = { + namespace, + search: 'foo*', + searchFields: ['foo'], + type: ['dashboard'], + sortField: 'name', + sortOrder: 'desc', + defaultSearchOperator: 'AND', + hasReference: { + type: 'foo', + id: '1', + }, + indexPattern: undefined, + filter: 'dashboard.attributes.otherField:<', + }; + + await expect(savedObjectsRepository.find(findOpts)).rejects.toMatchInlineSnapshot(` + [Error: KQLSyntaxError: Expected "(", "{", value, whitespace but "<" found. + dashboard.attributes.otherField:< + --------------------------------^: Bad Request] + `); + expect(getSearchDslNS.getSearchDsl).not.toHaveBeenCalled(); + expect(callAdminCluster).not.toHaveBeenCalled(); }); }); - 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}`, - error: { - type: 'version_conflict_engine_exception', - }, - }, - }; - default: - return { - update: { - _id: `${items.type}:${items.id}`, - ...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, + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + migrator.runMigrations = jest.fn(async () => + expect(callAdminCluster).not.toHaveBeenCalled() + ); + await expect(findSuccess({ type })).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveBeenCalledTimes(1); }); + }); - expect(invalidType).toMatchObject({ - ..._.pick(objects[1], 'id', 'type'), - error: SavedObjectsErrorHelpers.createGenericNotFoundError('invalid-type', 'invalid').output - .payload, - }); + describe('returns', () => { + it(`formats the ES response when there is no namespace`, async () => { + const noNamespaceSearchResults = generateSearchResults(); + callAdminCluster.mockReturnValue(noNamespaceSearchResults); + const count = noNamespaceSearchResults.hits.hits.length; - expect(secondUpdatedObject).toMatchObject({ - ..._.pick(objects[2], 'id', 'type', 'attributes', 'references'), - version: mockVersion, - }); + const response = await savedObjectsRepository.find({ type }); + + expect(response.total).toBe(count); + expect(response.saved_objects).toHaveLength(count); - expect(versionClashObject).toMatchObject({ - ..._.pick(objects[3], 'id', 'type'), - error: { statusCode: 409, message: 'version conflict, document already exists' }, + noNamespaceSearchResults.hits.hits.forEach((doc, i) => { + expect(response.saved_objects[i]).toEqual({ + id: doc._id.replace(/(index-pattern|config|globalType)\:/, ''), + type: doc._source.type, + ...mockTimestampFields, + version: mockVersion, + attributes: doc._source[doc._source.type], + references: [], + }); + }); }); - }); - 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' }, - }, - ]; + it(`formats the ES response when there is a namespace`, async () => { + const namespacedSearchResults = generateSearchResults(namespace); + callAdminCluster.mockReturnValue(namespacedSearchResults); + const count = namespacedSearchResults.hits.hits.length; - const { - saved_objects: [invalidType, invalidType2], - } = await savedObjectsRepository.bulkUpdate(objects); + const response = await savedObjectsRepository.find({ type, namespace }); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(response.total).toBe(count); + expect(response.saved_objects).toHaveLength(count); - expect(invalidType).toMatchObject({ - ..._.pick(objects[0], 'id', 'type'), - error: SavedObjectsErrorHelpers.createGenericNotFoundError('invalid-type', 'invalid').output - .payload, + namespacedSearchResults.hits.hits.forEach((doc, i) => { + expect(response.saved_objects[i]).toEqual({ + id: doc._id.replace(/(foo-namespace\:)?(index-pattern|config|globalType)\:/, ''), + type: doc._source.type, + ...mockTimestampFields, + version: mockVersion, + attributes: doc._source[doc._source.type], + references: [], + }); + }); }); - expect(invalidType2).toMatchObject({ - ..._.pick(objects[1], 'id', 'type'), - error: SavedObjectsErrorHelpers.createGenericNotFoundError('invalid-type', 'invalid 2') - .output.payload, + it(`should return empty results when attempting to find only invalid or hidden types`, async () => { + const test = async types => { + const result = await savedObjectsRepository.find({ type: types }); + expect(result).toEqual(expect.objectContaining({ saved_objects: [] })); + }; + + await test('unknownType'); + await test(HIDDEN_TYPE); + await test(['unknownType', HIDDEN_TYPE]); }); }); - it('accepts version', async () => { - const objects = [ - generateSavedObject({ - version: encodeHitVersion({ - _seq_no: 100, - _primary_term: 200, - }), - }), - generateSavedObject({ - version: encodeHitVersion({ - _seq_no: 300, - _primary_term: 400, - }), - }), - ]; + describe('search dsl', () => { + it(`passes mappings, registry, search, defaultSearchOperator, searchFields, type, sortField, sortOrder and hasReference to getSearchDsl`, async () => { + const relevantOpts = { + namespace, + search: 'foo*', + searchFields: ['foo'], + type: [type], + sortField: 'name', + sortOrder: 'desc', + defaultSearchOperator: 'AND', + hasReference: { + type: 'foo', + id: '1', + }, + kueryNode: undefined, + }; - mockValidResponse(objects); + await findSuccess(relevantOpts, namespace); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, relevantOpts); + }); + + it(`accepts KQL filter and passes kueryNode to getSearchDsl`, async () => { + const findOpts = { + namespace, + search: 'foo*', + searchFields: ['foo'], + type: ['dashboard'], + sortField: 'name', + sortOrder: 'desc', + defaultSearchOperator: 'AND', + hasReference: { + type: 'foo', + id: '1', + }, + indexPattern: undefined, + filter: 'dashboard.attributes.otherField: *', + }; + + await findSuccess(findOpts, namespace); + const { kueryNode } = getSearchDslNS.getSearchDsl.mock.calls[0][2]; + expect(kueryNode).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "dashboard.otherField", + }, + Object { + "type": "wildcard", + "value": "@kuery-wildcard@", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + } + `); + }); - await savedObjectsRepository.bulkUpdate(objects); + it(`supports multiple types`, async () => { + const types = ['config', 'index-pattern']; + await findSuccess({ type: types }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith( + mappings, + registry, + expect.objectContaining({ + type: types, + }) + ); + }); - const [ - , - { - body: [{ update: firstUpdate }, , { update: secondUpdate }], - }, - ] = callAdminCluster.mock.calls[0]; + it(`filters out invalid types`, async () => { + const types = ['config', 'unknownType', 'index-pattern']; + await findSuccess({ type: types }); - expect(firstUpdate).toMatchObject({ - if_seq_no: 100, - if_primary_term: 200, + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith( + mappings, + registry, + expect.objectContaining({ + type: ['config', 'index-pattern'], + }) + ); }); - expect(secondUpdate).toMatchObject({ - if_seq_no: 300, - if_primary_term: 400, + it(`filters out hidden types`, async () => { + const types = ['config', HIDDEN_TYPE, 'index-pattern']; + await findSuccess({ type: types }); + + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith( + mappings, + registry, + expect.objectContaining({ + type: ['config', 'index-pattern'], + }) + ); }); }); + }); - it('does not pass references if omitted', async () => { - const objects = [ - { - type: 'index-pattern', - id: `logstash-no-ref`, - attributes: { title: `Testing no-ref` }, - }, - ]; + describe('#get', () => { + const type = 'index-pattern'; + const id = 'logstash-*'; + const namespace = 'foo-namespace'; + + const getSuccess = async (type, id, options) => { + const response = getMockGetResponse({ type, id, namespace: options?.namespace }); + callAdminCluster.mockResolvedValue(response); + const result = await savedObjectsRepository.get(type, id, options); + expect(callAdminCluster).toHaveBeenCalledTimes(1); + return result; + }; - mockValidResponse(objects); + describe('cluster calls', () => { + it(`should use the ES get action`, async () => { + await getSuccess(type, id); + expectClusterCalls('get'); + }); - await savedObjectsRepository.bulkUpdate(objects); + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + await getSuccess(type, id, { namespace }); + expectClusterCallArgs({ id: `${namespace}:${type}:${id}` }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + await getSuccess(type, id); + expectClusterCallArgs({ id: `${type}:${id}` }); + }); - const [ - , - { - body: [, { doc: firstDoc }], - }, - ] = callAdminCluster.mock.calls[0]; + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + await getSuccess(NAMESPACE_AGNOSTIC_TYPE, id, { namespace }); + expectClusterCallArgs({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }); - expect(firstDoc).not.toMatchObject({ - references: [], + callAdminCluster.mockReset(); + await getSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); + expectClusterCallArgs({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }); }); }); - it('passes references if they are provided', async () => { - const objects = [ - generateSavedObject({ - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], - }), - ]; + describe('errors', () => { + const expectNotFoundError = async (type, id, options) => { + await expect(savedObjectsRepository.get(type, id, options)).rejects.toThrowError( + createGenericNotFoundError(type, id) + ); + }; - mockValidResponse(objects); + it(`throws when type is invalid`, async () => { + await expectNotFoundError('unknownType', id); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); - await savedObjectsRepository.bulkUpdate(objects); + it(`throws when type is hidden`, async () => { + await expectNotFoundError(HIDDEN_TYPE, id); + expect(callAdminCluster).not.toHaveBeenCalled(); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + it(`throws when ES is unable to find the document during get`, async () => { + callAdminCluster.mockResolvedValue({ found: false }); + await expectNotFoundError(type, id); + expectClusterCalls('get'); + }); - const [ - , - { - body: [, { doc }], - }, - ] = callAdminCluster.mock.calls[0]; + it(`throws when ES is unable to find the index during get`, async () => { + callAdminCluster.mockResolvedValue({ status: 404 }); + await expectNotFoundError(type, id); + expectClusterCalls('get'); + }); - expect(doc).toMatchObject({ - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], + 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 }); + callAdminCluster.mockResolvedValue(response); + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + expectClusterCalls('get'); }); }); - 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); + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + migrator.runMigrations = jest.fn(async () => + expect(callAdminCluster).not.toHaveBeenCalled() + ); + await expect(getSuccess(type, id)).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveBeenCalledTimes(1); + }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + describe('returns', () => { + it(`formats the ES response`, async () => { + const result = await getSuccess(type, id); + expect(result).toEqual({ + id, + type, + updated_at: mockTimestamp, + version: mockVersion, + attributes: { + title: 'Testing', + }, + references: [], + }); + }); - const [ - , - { - body: [, { doc }], - }, - ] = callAdminCluster.mock.calls[0]; + it(`includes namespaces if type is multi-namespace`, async () => { + const result = await getSuccess(MULTI_NAMESPACE_TYPE, id); + expect(result).toMatchObject({ + namespaces: expect.any(Array), + }); + }); - expect(doc).toMatchObject({ - references: [], + it(`doesn't include namespaces if type is not multi-namespace`, async () => { + const result = await getSuccess(type, id); + expect(result).not.toMatchObject({ + namespaces: expect.anything(), + }); }); }); + }); - it('defaults to a refresh setting of `wait_for`', async () => { - const objects = [ - { - type: 'index-pattern', - id: `logstash-no-ref`, - attributes: { title: `Testing no-ref` }, - references: [], + describe('#incrementCounter', () => { + const type = 'config'; + const id = 'one'; + const field = 'buildNum'; + const namespace = 'foo-namespace'; + + const incrementCounterSuccess = async (type, id, field, options) => { + const isMultiNamespace = registry.isMultiNamespace(type); + if (isMultiNamespace) { + const response = getMockGetResponse({ type, id, namespace: options?.namespace }); + callAdminCluster.mockResolvedValueOnce(response); // this._callCluster('get', ...) + } + callAdminCluster.mockImplementation((method, params) => ({ + _id: params.id, + ...mockVersionProps, + _index: '.kibana', + get: { + found: true, + _source: { + type, + ...mockTimestampFields, + [type]: { + [field]: 8468, + defaultIndex: 'logstash-*', + }, + }, }, - ]; - - mockValidResponse(objects); + })); + const result = await savedObjectsRepository.incrementCounter(type, id, field, options); + expect(callAdminCluster).toHaveBeenCalledTimes(isMultiNamespace ? 2 : 1); + return result; + }; - await savedObjectsRepository.bulkUpdate(objects); + describe('cluster calls', () => { + it(`should use the ES update action if type is not multi-namespace`, async () => { + await incrementCounterSuccess(type, id, field, { namespace }); + expectClusterCalls('update'); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + it(`should use the ES get action then update action if type is multi-namespace, ID is defined, and overwrite=true`, async () => { + await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, field, { namespace }); + expectClusterCalls('get', 'update'); + }); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ refresh: 'wait_for' }); - }); + it(`defaults to a refresh setting of wait_for`, async () => { + await incrementCounterSuccess(type, id, field, { namespace }); + expectClusterCallArgs({ refresh: 'wait_for' }); + }); - it('accepts a custom refresh setting', async () => { - const objects = [ - { - type: 'index-pattern', - id: `logstash-no-ref`, - attributes: { title: `Testing no-ref` }, - references: [], - }, - ]; + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + await incrementCounterSuccess(type, id, field, { namespace, refresh }); + expectClusterCallArgs({ refresh }); + }); - mockValidResponse(objects); + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + await incrementCounterSuccess(type, id, field, { namespace }); + expectClusterCallArgs({ id: `${namespace}:${type}:${id}` }); + }); - await savedObjectsRepository.bulkUpdate(objects, { refresh: true }); + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + await incrementCounterSuccess(type, id, field); + expectClusterCallArgs({ id: `${type}:${id}` }); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + await incrementCounterSuccess(NAMESPACE_AGNOSTIC_TYPE, id, field, { namespace }); + expectClusterCallArgs({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ refresh: true }); + callAdminCluster.mockReset(); + await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, field, { namespace }); + expectClusterCallArgs({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }); + }); }); - it(`prepends namespace to the id but doesn't add namespace to body when providing namespace for namespaced type`, async () => { - const objects = [generateSavedObject(), generateSavedObject()]; + describe('errors', () => { + const expectUnsupportedTypeError = async (type, id, field) => { + await expect(savedObjectsRepository.incrementCounter(type, id, field)).rejects.toThrowError( + createUnsupportedTypeError(type) + ); + }; - mockValidResponse(objects); + it(`throws when type is not a string`, async () => { + const test = async type => { + await expect( + savedObjectsRepository.incrementCounter(type, id, field) + ).rejects.toThrowError(`"type" argument must be a string`); + expect(callAdminCluster).not.toHaveBeenCalled(); + }; - await savedObjectsRepository.bulkUpdate(objects, { - namespace: 'foo-namespace', + await test(null); + await test(42); + await test(false); + await test({}); }); - const [ - , - { - body: [ - { update: firstUpdate }, - { doc: firstUpdateDoc }, - { update: secondUpdate }, - { doc: secondUpdateDoc }, - ], - }, - ] = callAdminCluster.mock.calls[0]; + it(`throws when counterFieldName is not a string`, async () => { + const test = async field => { + await expect( + savedObjectsRepository.incrementCounter(type, id, field) + ).rejects.toThrowError(`"counterFieldName" argument must be a string`); + expect(callAdminCluster).not.toHaveBeenCalled(); + }; - expect(firstUpdate).toMatchObject({ - _id: 'foo-namespace:index-pattern:logstash-1', - _index: '.kibana-test', + await test(null); + await test(42); + await test(false); + await test({}); }); - expect(firstUpdateDoc).toMatchObject({ - updated_at: mockTimestamp, - 'index-pattern': { title: 'Testing 1' }, - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], + it(`throws when type is invalid`, async () => { + await expectUnsupportedTypeError('unknownType', id, field); + expect(callAdminCluster).not.toHaveBeenCalled(); }); - expect(secondUpdate).toMatchObject({ - _id: 'foo-namespace:index-pattern:logstash-2', - _index: '.kibana-test', + it(`throws when type is hidden`, async () => { + await expectUnsupportedTypeError(HIDDEN_TYPE, id, field); + expect(callAdminCluster).not.toHaveBeenCalled(); }); - expect(secondUpdateDoc).toMatchObject({ - updated_at: mockTimestamp, - 'index-pattern': { title: 'Testing 2' }, - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], + 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', + }); + callAdminCluster.mockResolvedValue(response); // this._callCluster('get', ...) + await expect( + savedObjectsRepository.incrementCounter(MULTI_NAMESPACE_TYPE, id, field, { namespace }) + ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id)); + expectClusterCalls('get'); }); }); - 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); + describe('migration', () => { + beforeEach(() => { + migrator.migrateDocument.mockImplementation(mockMigrateDocument); + }); - await savedObjectsRepository.bulkUpdate(objects); + it(`waits until migrations are complete before proceeding`, async () => { + migrator.runMigrations = jest.fn(async () => + expect(callAdminCluster).not.toHaveBeenCalled() + ); + await expect( + incrementCounterSuccess(type, id, field, { namespace }) + ).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveBeenCalledTimes(1); + }); - const [ - , - { - body: [ - { update: firstUpdate }, - { doc: firstUpdateDoc }, - { update: secondUpdate }, - { doc: secondUpdateDoc }, - ], - }, - ] = callAdminCluster.mock.calls[0]; + it(`migrates a document and serializes the migrated doc`, async () => { + const migrationVersion = mockMigrationVersion; + await incrementCounterSuccess(type, id, field, { migrationVersion }); + const attributes = { buildNum: 1 }; // this is added by the incrementCounter function + const doc = { type, id, attributes, migrationVersion, ...mockTimestampFields }; + expectMigrationArgs(doc); - expect(firstUpdate).toMatchObject({ - _id: 'index-pattern:logstash-1', - _index: '.kibana-test', + const migratedDoc = migrator.migrateDocument(doc); + expect(serializer.savedObjectToRaw).toHaveBeenLastCalledWith(migratedDoc); }); + }); - expect(firstUpdateDoc).toMatchObject({ - updated_at: mockTimestamp, - 'index-pattern': { title: 'Testing 1' }, - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', + describe('returns', () => { + it(`formats the ES response`, async () => { + callAdminCluster.mockImplementation((method, params) => ({ + _id: params.id, + ...mockVersionProps, + _index: '.kibana', + get: { + found: true, + _source: { + type: 'config', + ...mockTimestampFields, + config: { + buildNum: 8468, + defaultIndex: 'logstash-*', + }, + }, }, - ], - }); - - expect(secondUpdate).toMatchObject({ - _id: 'index-pattern:logstash-2', - _index: '.kibana-test', - }); + })); - expect(secondUpdateDoc).toMatchObject({ - updated_at: mockTimestamp, - 'index-pattern': { title: 'Testing 2' }, - references: [ + const response = await savedObjectsRepository.incrementCounter( + 'config', + '6.0.0-alpha1', + 'buildNum', { - name: 'ref_0', - type: 'test', - id: '1', + namespace: 'foo-namespace', + } + ); + + expect(response).toEqual({ + type: 'config', + id: '6.0.0-alpha1', + ...mockTimestampFields, + version: mockVersion, + attributes: { + buildNum: 8468, + defaultIndex: 'logstash-*', }, - ], + }); }); }); + }); - 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', - }), - ]; + describe('#deleteFromNamespaces', () => { + const id = 'some-id'; + const type = MULTI_NAMESPACE_TYPE; + const namespace1 = 'default'; + const namespace2 = 'foo-namespace'; + const namespace3 = 'bar-namespace'; + + const mockGetResponse = (type, id, namespaces) => { + // mock a document that exists in two namespaces + const mockResponse = getMockGetResponse({ type, id }); + mockResponse._source.namespaces = namespaces; + callAdminCluster.mockResolvedValueOnce(mockResponse); // this._callCluster('get', ...) + }; + + const deleteFromNamespacesSuccess = async ( + type, + id, + namespaces, + currentNamespaces, + options + ) => { + mockGetResponse(type, id, currentNamespaces); // this._callCluster('get', ...) + const isDelete = currentNamespaces.every(namespace => namespaces.includes(namespace)); + callAdminCluster.mockResolvedValue({ + _id: `${type}:${id}`, + ...mockVersionProps, + result: isDelete ? 'deleted' : 'updated', + }); // this._writeToCluster('delete', ...) *or* this._writeToCluster('update', ...) + const result = await savedObjectsRepository.deleteFromNamespaces( + type, + id, + namespaces, + options + ); + expect(callAdminCluster).toHaveBeenCalledTimes(2); + return result; + }; - mockValidResponse(objects); + describe('cluster calls', () => { + describe('delete action', () => { + const deleteFromNamespacesSuccessDelete = async (expectFn, options, _type = type) => { + const test = async namespaces => { + await deleteFromNamespacesSuccess(_type, id, namespaces, namespaces, options); + expectFn(); + callAdminCluster.mockReset(); + }; + await test([namespace1]); + await test([namespace1, namespace2]); + }; + + it(`should use ES get action then delete action if the object has no namespaces remaining`, async () => { + const expectFn = () => expectClusterCalls('get', 'delete'); + await deleteFromNamespacesSuccessDelete(expectFn); + }); - await savedObjectsRepository.bulkUpdate(objects); + it(`formats the ES requests`, async () => { + const expectFn = () => { + expectClusterCallArgs({ id: `${type}:${id}` }, 1); + const versionProperties = { + if_seq_no: mockVersionProps._seq_no, + if_primary_term: mockVersionProps._primary_term, + }; + expectClusterCallArgs({ id: `${type}:${id}`, ...versionProperties }, 2); + }; + await deleteFromNamespacesSuccessDelete(expectFn); + }); - const [ - , - { - body: [{ update }, { doc }], - }, - ] = callAdminCluster.mock.calls[0]; + it(`defaults to a refresh setting of wait_for`, async () => { + await deleteFromNamespacesSuccessDelete(() => + expectClusterCallArgs({ refresh: 'wait_for' }, 2) + ); + }); - expect(update).toMatchObject({ - _id: 'globaltype:foo', - _index: '.kibana-test', - }); + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + const expectFn = () => expectClusterCallArgs({ refresh }, 2); + await deleteFromNamespacesSuccessDelete(expectFn, { refresh }); + }); - expect(doc).toMatchObject({ - updated_at: mockTimestamp, - globaltype: { title: 'Testing 1' }, - references: [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], + it(`should use default index`, async () => { + const expectFn = () => expectClusterCallArgs({ index: '.kibana-test' }, 2); + await deleteFromNamespacesSuccessDelete(expectFn); + }); + + it(`should use custom index`, async () => { + const expectFn = () => expectClusterCallArgs({ index: 'custom' }, 2); + await deleteFromNamespacesSuccessDelete(expectFn, {}, MULTI_NAMESPACE_CUSTOM_INDEX_TYPE); + }); }); - }); - }); - describe('#incrementCounter', () => { - beforeEach(() => { - callAdminCluster.mockImplementation((method, params) => ({ - _id: params.id, - ...mockVersionProps, - _index: '.kibana', - get: { - found: true, - _source: { - type: 'config', - ...mockTimestampFields, - config: { - buildNum: 8468, - defaultIndex: 'logstash-*', - }, - }, - }, - })); - }); + describe('update action', () => { + const deleteFromNamespacesSuccessUpdate = async (expectFn, options, _type = type) => { + const test = async remaining => { + const currentNamespaces = [namespace1].concat(remaining); + await deleteFromNamespacesSuccess(_type, id, [namespace1], currentNamespaces, options); + expectFn(); + callAdminCluster.mockReset(); + }; + await test([namespace2]); + await test([namespace2, namespace3]); + }; + + it(`should use ES get action then update action if the object has one or more namespaces remaining`, async () => { + await deleteFromNamespacesSuccessUpdate(() => expectClusterCalls('get', 'update')); + }); - it('formats Elasticsearch response', async () => { - callAdminCluster.mockImplementation((method, params) => ({ - _id: params.id, - ...mockVersionProps, - _index: '.kibana', - get: { - found: true, - _source: { - type: 'config', - ...mockTimestampFields, - config: { - buildNum: 8468, - defaultIndex: 'logstash-*', - }, - }, - }, - })); + it(`formats the ES requests`, async () => { + let ctr = 0; + const expectFn = () => { + expectClusterCallArgs({ id: `${type}:${id}` }, 1); + const namespaces = ctr++ === 0 ? [namespace2] : [namespace2, namespace3]; + const versionProperties = { + if_seq_no: mockVersionProps._seq_no, + if_primary_term: mockVersionProps._primary_term, + }; + expectClusterCallArgs( + { + id: `${type}:${id}`, + ...versionProperties, + body: { doc: { ...mockTimestampFields, namespaces } }, + }, + 2 + ); + }; + await deleteFromNamespacesSuccessUpdate(expectFn); + }); - const response = await savedObjectsRepository.incrementCounter( - 'config', - '6.0.0-alpha1', - 'buildNum', - { - namespace: 'foo-namespace', - } - ); + it(`defaults to a refresh setting of wait_for`, async () => { + const expectFn = () => expectClusterCallArgs({ refresh: 'wait_for' }, 2); + await deleteFromNamespacesSuccessUpdate(expectFn); + }); - expect(response).toEqual({ - type: 'config', - id: '6.0.0-alpha1', - ...mockTimestampFields, - version: mockVersion, - attributes: { - buildNum: 8468, - defaultIndex: 'logstash-*', - }, + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + const expectFn = () => expectClusterCallArgs({ refresh }, 2); + await deleteFromNamespacesSuccessUpdate(expectFn, { refresh }); + }); + + it(`should use default index`, async () => { + const expectFn = () => expectClusterCallArgs({ index: '.kibana-test' }, 2); + await deleteFromNamespacesSuccessUpdate(expectFn); + }); + + it(`should use custom index`, async () => { + const expectFn = () => expectClusterCallArgs({ index: 'custom' }, 2); + await deleteFromNamespacesSuccessUpdate(expectFn, {}, MULTI_NAMESPACE_CUSTOM_INDEX_TYPE); + }); }); }); - it('migrates the doc if an upsert is required', async () => { - migrator.migrateDocument = doc => { - doc.attributes.buildNum = 42; - doc.migrationVersion = { foo: '2.3.4' }; - doc.references = [{ name: 'search_0', type: 'search', id: '123' }]; - return doc; + describe('errors', () => { + const expectNotFoundError = async (type, id, namespaces, options) => { + await expect( + savedObjectsRepository.deleteFromNamespaces(type, id, namespaces, options) + ).rejects.toThrowError(createGenericNotFoundError(type, id)); + }; + const expectBadRequestError = async (type, id, namespaces, message) => { + await expect( + savedObjectsRepository.deleteFromNamespaces(type, id, namespaces) + ).rejects.toThrowError(createBadRequestError(message)); }; - await savedObjectsRepository.incrementCounter('config', 'doesnotexist', 'buildNum', { - namespace: 'foo-namespace', + it(`throws when type is invalid`, async () => { + await expectNotFoundError('unknownType', id, [namespace1, namespace2]); + expect(callAdminCluster).not.toHaveBeenCalled(); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - body: { - upsert: { - config: { buildNum: 42 }, - migrationVersion: { foo: '2.3.4' }, - type: 'config', - ...mockTimestampFields, - references: [{ name: 'search_0', type: 'search', id: '123' }], - }, - }, + it(`throws when type is hidden`, async () => { + await expectNotFoundError(HIDDEN_TYPE, id, [namespace1, namespace2]); + expect(callAdminCluster).not.toHaveBeenCalled(); }); - }); - it('defaults to a refresh setting of `wait_for`', async () => { - await savedObjectsRepository.incrementCounter('config', 'doesnotexist', 'buildNum', { - namespace: 'foo-namespace', + it(`throws when type is not namespace-agnostic`, async () => { + const test = async type => { + const message = `${type} doesn't support multiple namespaces`; + await expectBadRequestError(type, id, [namespace1, namespace2], message); + expect(callAdminCluster).not.toHaveBeenCalled(); + }; + await test('index-pattern'); + await test(NAMESPACE_AGNOSTIC_TYPE); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: 'wait_for', + it(`throws when namespaces is an empty array`, async () => { + const test = async namespaces => { + const message = 'namespaces must be a non-empty array of strings'; + await expectBadRequestError(type, id, namespaces, message); + expect(callAdminCluster).not.toHaveBeenCalled(); + }; + await test([]); }); - }); - it('accepts a custom refresh setting', async () => { - await savedObjectsRepository.incrementCounter('config', 'doesnotexist', 'buildNum', { - namespace: 'foo-namespace', - refresh: true, + it(`throws when ES is unable to find the document during get`, async () => { + callAdminCluster.mockResolvedValue({ found: false }); // this._callCluster('get', ...) + await expectNotFoundError(type, id, [namespace1, namespace2]); + expectClusterCalls('get'); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); - expect(callAdminCluster.mock.calls[0][1]).toMatchObject({ - refresh: true, + it(`throws when ES is unable to find the index during get`, async () => { + callAdminCluster.mockResolvedValue({ status: 404 }); // this._callCluster('get', ...) + await expectNotFoundError(type, id, [namespace1, namespace2]); + expectClusterCalls('get'); }); - }); - it(`prepends namespace to the id but doesn't add namespace to body when providing namespace for namespaced type`, async () => { - await savedObjectsRepository.incrementCounter('config', '6.0.0-alpha1', 'buildNum', { - namespace: 'foo-namespace', + it(`throws when the document exists, but not in this namespace`, async () => { + mockGetResponse(type, id, [namespace1]); // this._callCluster('get', ...) + await expectNotFoundError(type, id, [namespace1], { namespace: 'some-other-namespace' }); + expectClusterCalls('get'); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + it(`throws when ES is unable to find the document during delete`, async () => { + mockGetResponse(type, id, [namespace1]); // this._callCluster('get', ...) + callAdminCluster.mockResolvedValue({ result: 'not_found' }); // this._writeToCluster('delete', ...) + await expectNotFoundError(type, id, [namespace1]); + expectClusterCalls('get', 'delete'); + }); + + it(`throws when ES is unable to find the index during delete`, async () => { + mockGetResponse(type, id, [namespace1]); // this._callCluster('get', ...) + callAdminCluster.mockResolvedValue({ error: { type: 'index_not_found_exception' } }); // this._writeToCluster('delete', ...) + await expectNotFoundError(type, id, [namespace1]); + expectClusterCalls('get', 'delete'); + }); + + it(`throws when ES returns an unexpected response`, async () => { + mockGetResponse(type, id, [namespace1]); // this._callCluster('get', ...) + callAdminCluster.mockResolvedValue({ result: 'something unexpected' }); // this._writeToCluster('delete', ...) + await expect( + savedObjectsRepository.deleteFromNamespaces(type, id, [namespace1]) + ).rejects.toThrowError('Unexpected Elasticsearch DELETE response'); + expectClusterCalls('get', 'delete'); + }); + + it(`throws when ES is unable to find the document during update`, async () => { + mockGetResponse(type, id, [namespace1, namespace2]); // this._callCluster('get', ...) + callAdminCluster.mockResolvedValue({ status: 404 }); // this._writeToCluster('update', ...) + await expectNotFoundError(type, id, [namespace1]); + expectClusterCalls('get', 'update'); + }); + }); - const requestDoc = callAdminCluster.mock.calls[0][1]; - expect(requestDoc.id).toBe('foo-namespace:config:6.0.0-alpha1'); - expect(requestDoc.body.script.params.type).toBe('config'); - expect(requestDoc.body.upsert.type).toBe('config'); - expect(requestDoc).toHaveProperty('body.upsert.config'); + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + let callAdminClusterCount = 0; + migrator.runMigrations = jest.fn(async () => + // runMigrations should resolve before callAdminCluster is initiated + expect(callAdminCluster).toHaveBeenCalledTimes(callAdminClusterCount++) + ); + await expect( + deleteFromNamespacesSuccess(type, id, [namespace1], [namespace1]) + ).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveReturnedTimes(2); + }); }); - it(`doesn't prepend namespace to the id or add namespace property when providing no namespace for namespaced type`, async () => { - await savedObjectsRepository.incrementCounter('config', '6.0.0-alpha1', 'buildNum'); + describe('returns', () => { + it(`returns an empty object on success (delete)`, async () => { + const test = async namespaces => { + const result = await deleteFromNamespacesSuccess(type, id, namespaces, namespaces); + expect(result).toEqual({}); + callAdminCluster.mockReset(); + }; + await test([namespace1]); + await test([namespace1, namespace2]); + }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + it(`returns an empty object on success (update)`, async () => { + const test = async remaining => { + const currentNamespaces = [namespace1].concat(remaining); + const result = await deleteFromNamespacesSuccess( + type, + id, + [namespace1], + currentNamespaces + ); + expect(result).toEqual({}); + callAdminCluster.mockReset(); + }; + await test([namespace2]); + await test([namespace2, namespace3]); + }); - const requestDoc = callAdminCluster.mock.calls[0][1]; - expect(requestDoc.id).toBe('config:6.0.0-alpha1'); - expect(requestDoc.body.script.params.type).toBe('config'); - expect(requestDoc.body.upsert.type).toBe('config'); - expect(requestDoc).toHaveProperty('body.upsert.config'); + it(`succeeds when the document doesn't exist in all of the targeted namespaces`, async () => { + const namespaces = [namespace2]; + const currentNamespaces = [namespace1]; + const result = await deleteFromNamespacesSuccess(type, id, namespaces, currentNamespaces); + expect(result).toEqual({}); + }); }); + }); - it(`doesn't prepend namespace to the id or add namespace property when providing namespace for namespace agnostic type`, async () => { - callAdminCluster.mockImplementation((method, params) => ({ - _id: params.id, + describe('#update', () => { + const id = 'logstash-*'; + const type = 'index-pattern'; + const attributes = { title: 'Testing' }; + const namespace = 'foo-namespace'; + const references = [ + { + name: 'ref_0', + type: 'test', + id: '1', + }, + ]; + + const updateSuccess = async (type, id, attributes, options) => { + if (registry.isMultiNamespace(type)) { + const mockGetResponse = getMockGetResponse({ type, id, namespace: options?.namespace }); + callAdminCluster.mockResolvedValueOnce(mockGetResponse); // this._callCluster('get', ...) + } + callAdminCluster.mockResolvedValue({ + _id: `${type}:${id}`, ...mockVersionProps, - _index: '.kibana', - get: { - found: true, - _source: { - type: 'globaltype', - ...mockTimestampFields, - globaltype: { - counter: 1, - }, - }, - }, - })); + result: 'updated', + ...(registry.isMultiNamespace(type) && { + // don't need the rest of the source for test purposes, just the namespaces attribute + get: { _source: { namespaces: [options?.namespace ?? 'default'] } }, + }), + }); // this._writeToCluster('update', ...) + const result = await savedObjectsRepository.update(type, id, attributes, options); + expect(callAdminCluster).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 2 : 1); + return result; + }; - await savedObjectsRepository.incrementCounter('globaltype', 'foo', 'counter', { - namespace: 'foo-namespace', + describe('cluster calls', () => { + it(`should use the ES get action then update action when type is multi-namespace`, async () => { + await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + expectClusterCalls('get', 'update'); }); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + it(`should use the ES update action when type is not multi-namespace`, async () => { + await updateSuccess(type, id, attributes); + expectClusterCalls('update'); + }); - const requestDoc = callAdminCluster.mock.calls[0][1]; - expect(requestDoc.id).toBe('globaltype:foo'); - expect(requestDoc.body.script.params.type).toBe('globaltype'); - expect(requestDoc.body.upsert.type).toBe('globaltype'); - expect(requestDoc).toHaveProperty('body.upsert.globaltype'); - }); + it(`defaults to no references array`, async () => { + await updateSuccess(type, id, attributes); + expectClusterCallArgs({ + body: { doc: expect.not.objectContaining({ references: expect.anything() }) }, + }); + }); - it('should assert that the "type" and "counterFieldName" arguments are strings', () => { - expect.assertions(6); - - expect( - savedObjectsRepository.incrementCounter(null, '6.0.0-alpha1', 'buildNum', { - namespace: 'foo-namespace', - }) - ).rejects.toEqual(new Error('"type" argument must be a string')); - - expect( - savedObjectsRepository.incrementCounter(42, '6.0.0-alpha1', 'buildNum', { - namespace: 'foo-namespace', - }) - ).rejects.toEqual(new Error('"type" argument must be a string')); - - expect( - savedObjectsRepository.incrementCounter({}, '6.0.0-alpha1', 'buildNum', { - namespace: 'foo-namespace', - }) - ).rejects.toEqual(new Error('"type" argument must be a string')); - - expect( - savedObjectsRepository.incrementCounter('config', '6.0.0-alpha1', null, { - namespace: 'foo-namespace', - }) - ).rejects.toEqual(new Error('"counterFieldName" argument must be a string')); - - expect( - savedObjectsRepository.incrementCounter('config', '6.0.0-alpha1', 42, { - namespace: 'foo-namespace', - }) - ).rejects.toEqual(new Error('"counterFieldName" argument must be a string')); - - expect( - savedObjectsRepository.incrementCounter( - 'config', - '6.0.0-alpha1', - {}, - { - namespace: 'foo-namespace', - } - ) - ).rejects.toEqual(new Error('"counterFieldName" argument must be a string')); - }); - }); + it(`accepts custom references array`, async () => { + const test = async references => { + await updateSuccess(type, id, attributes, { references }); + expectClusterCallArgs({ + body: { doc: expect.objectContaining({ references }) }, + }); + callAdminCluster.mockReset(); + }; + await test(references); + await test(['string']); + await test([]); + }); + + it(`doesn't accept custom references if not an array`, async () => { + const test = async references => { + await updateSuccess(type, id, attributes, { references }); + expectClusterCallArgs({ + body: { doc: expect.not.objectContaining({ references: expect.anything() }) }, + }); + callAdminCluster.mockReset(); + }; + await test('string'); + await test(123); + await test(true); + await test(null); + }); + + it(`defaults to a refresh setting of wait_for`, async () => { + await updateSuccess(type, id, { foo: 'bar' }); + expectClusterCallArgs({ refresh: 'wait_for' }); + }); + + it(`accepts a custom refresh setting`, async () => { + const refresh = 'foo'; + await updateSuccess(type, id, { foo: 'bar' }, { refresh }); + expectClusterCallArgs({ refresh }); + }); + + it(`defaults to the version of the existing document when type is multi-namespace`, async () => { + await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes, { references }); + const versionProperties = { + if_seq_no: mockVersionProps._seq_no, + if_primary_term: mockVersionProps._primary_term, + }; + expectClusterCallArgs(versionProperties, 2); + }); + + it(`accepts version`, async () => { + await updateSuccess(type, id, attributes, { + version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }), + }); + expectClusterCallArgs({ if_seq_no: 100, if_primary_term: 200 }); + }); - describe('types on custom index', () => { - it("should error when attempting to 'update' an unsupported type", async () => { - await expect( - savedObjectsRepository.update('hiddenType', 'bogus', { title: 'some title' }) - ).rejects.toEqual(new Error('Saved object [hiddenType/bogus] not found')); - }); - }); + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + await updateSuccess(type, id, attributes, { namespace }); + expectClusterCallArgs({ id: expect.stringMatching(`${namespace}:${type}:${id}`) }); + }); - describe('unsupported types', () => { - it("should error when attempting to 'update' an unsupported type", async () => { - await expect( - savedObjectsRepository.update('hiddenType', 'bogus', { title: 'some title' }) - ).rejects.toEqual(new Error('Saved object [hiddenType/bogus] not found')); - }); + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + await updateSuccess(type, id, attributes, { references }); + expectClusterCallArgs({ id: expect.stringMatching(`${type}:${id}`) }); + }); - it("should error when attempting to 'get' an unsupported type", async () => { - await expect(savedObjectsRepository.get('hiddenType')).rejects.toEqual( - new Error('Not Found') - ); - }); + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + await updateSuccess(NAMESPACE_AGNOSTIC_TYPE, id, attributes, { namespace }); + expectClusterCallArgs({ id: expect.stringMatching(`${NAMESPACE_AGNOSTIC_TYPE}:${id}`) }); - it("should return an error object when attempting to 'create' an unsupported type", async () => { - await expect( - savedObjectsRepository.create('hiddenType', { title: 'some title' }) - ).rejects.toEqual(new Error("Unsupported saved object type: 'hiddenType': Bad Request")); - }); + callAdminCluster.mockReset(); + await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes, { namespace }); + expectClusterCallArgs({ id: expect.stringMatching(`${MULTI_NAMESPACE_TYPE}:${id}`) }, 2); + }); - it("should not return hidden saved ojects when attempting to 'find' support and unsupported types", async () => { - callAdminCluster.mockReturnValue({ - hits: { - total: 1, - hits: [ - { - _id: 'one', - _source: { - updated_at: mockTimestamp, - type: 'config', - }, - references: [], - }, - ], - }, + it(`includes _sourceIncludes when type is multi-namespace`, async () => { + await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + expectClusterCallArgs({ _sourceIncludes: ['namespaces'] }, 2); }); - const results = await savedObjectsRepository.find({ type: ['hiddenType', 'config'] }); - expect(results).toEqual({ - total: 1, - saved_objects: [ - { - id: 'one', - references: [], - type: 'config', - updated_at: mockTimestamp, - }, - ], - page: 1, - per_page: 20, + + it(`doesn't include _sourceIncludes when type is not multi-namespace`, async () => { + await updateSuccess(type, id, attributes); + expect(callAdminCluster).toHaveBeenLastCalledWith( + expect.any(String), + expect.not.objectContaining({ + _sourceIncludes: expect.anything(), + }) + ); }); }); - it("should return empty results when attempting to 'find' an unsupported type", async () => { - callAdminCluster.mockReturnValue({ - hits: { - total: 0, - hits: [], - }, + describe('errors', () => { + const expectNotFoundError = async (type, id) => { + await expect(savedObjectsRepository.update(type, id)).rejects.toThrowError( + createGenericNotFoundError(type, id) + ); + }; + + it(`throws when type is invalid`, async () => { + await expectNotFoundError('unknownType', id); + expect(callAdminCluster).not.toHaveBeenCalled(); }); - const results = await savedObjectsRepository.find({ type: 'hiddenType' }); - expect(results).toEqual({ - total: 0, - saved_objects: [], - page: 1, - per_page: 20, + + it(`throws when type is hidden`, async () => { + await expectNotFoundError(HIDDEN_TYPE, id); + expect(callAdminCluster).not.toHaveBeenCalled(); }); - }); - it("should return empty results when attempting to 'find' more than one unsupported types", async () => { - const findParams = { type: ['hiddenType', 'hiddenType2'] }; - callAdminCluster.mockReturnValue({ - status: 200, - hits: { - total: 0, - hits: [], - }, + it(`throws when ES is unable to find the document during get`, async () => { + callAdminCluster.mockResolvedValue({ found: false }); // this._callCluster('get', ...) + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + expectClusterCalls('get'); }); - const results = await savedObjectsRepository.find(findParams); - expect(results).toEqual({ - total: 0, - saved_objects: [], - page: 1, - per_page: 20, + + it(`throws when ES is unable to find the index during get`, async () => { + callAdminCluster.mockResolvedValue({ status: 404 }); // this._callCluster('get', ...) + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + expectClusterCalls('get'); }); - }); - it("should error when attempting to 'delete' hidden types", async () => { - await expect(savedObjectsRepository.delete('hiddenType')).rejects.toEqual( - new Error('Not Found') - ); + 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 }); + callAdminCluster.mockResolvedValue(response); // this._callCluster('get', ...) + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + expectClusterCalls('get'); + }); + + it(`throws when ES is unable to find the document during update`, async () => { + callAdminCluster.mockResolvedValue({ status: 404 }); // this._writeToCluster('update', ...) + await expectNotFoundError(type, id); + expectClusterCalls('update'); + }); }); - it("should error when attempting to 'bulkCreate' an unsupported type", async () => { - callAdminCluster.mockReturnValue({ - items: [ - { - index: { - _id: 'one', - _seq_no: 1, - _primary_term: 1, - _type: 'config', - attributes: { - title: 'Test One', - }, - }, - }, - ], - }); - const results = await savedObjectsRepository.bulkCreate([ - { type: 'config', id: 'one', attributes: { title: 'Test One' } }, - { type: 'hiddenType', id: 'two', attributes: { title: 'Test Two' } }, - ]); - expect(results).toEqual({ - saved_objects: [ - { - type: 'config', - id: 'one', - attributes: { title: 'Test One' }, - references: [], - version: 'WzEsMV0=', - updated_at: mockTimestamp, - }, - { - error: { - error: 'Bad Request', - message: "Unsupported saved object type: 'hiddenType': Bad Request", - statusCode: 400, - }, - id: 'two', - type: 'hiddenType', - }, - ], + describe('migration', () => { + it(`waits until migrations are complete before proceeding`, async () => { + let callAdminClusterCount = 0; + migrator.runMigrations = jest.fn(async () => + // runMigrations should resolve before callAdminCluster is initiated + expect(callAdminCluster).toHaveBeenCalledTimes(callAdminClusterCount++) + ); + await expect(updateSuccess(type, id, attributes)).resolves.toBeDefined(); + expect(migrator.runMigrations).toHaveReturnedTimes(1); }); }); - it("should error when attempting to 'incrementCounter' for an unsupported type", async () => { - await expect( - savedObjectsRepository.incrementCounter('hiddenType', 'doesntmatter', 'fieldArg') - ).rejects.toEqual(new Error("Unsupported saved object type: 'hiddenType': Bad Request")); + describe('returns', () => { + it(`returns _seq_no and _primary_term encoded as version`, async () => { + const result = await updateSuccess(type, id, attributes, { + namespace, + references, + }); + expect(result).toEqual({ + id, + type, + ...mockTimestampFields, + version: mockVersion, + attributes, + references, + }); + }); + + it(`includes namespaces if type is multi-namespace`, async () => { + const result = await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); + expect(result).toMatchObject({ + namespaces: expect.any(Array), + }); + }); + + it(`doesn't include namespaces if type is not multi-namespace`, async () => { + const result = await updateSuccess(type, id, attributes); + expect(result).not.toMatchObject({ + namespaces: expect.anything(), + }); + }); }); }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 72a7867854b60f..5f17c117927638 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -45,6 +45,8 @@ import { SavedObjectsBulkUpdateObject, SavedObjectsBulkUpdateOptions, SavedObjectsDeleteOptions, + SavedObjectsAddToNamespacesOptions, + SavedObjectsDeleteFromNamespacesOptions, } from '../saved_objects_client'; import { SavedObject, @@ -60,20 +62,12 @@ import { validateConvertFilterToKueryNode } from './filter_utils'; // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -type Left = { - tag: 'Left'; - error: T; -}; +type Left = { tag: 'Left'; error: Record }; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -type Right = { - tag: 'Right'; - value: T; -}; - -type Either = Left | Right; -const isLeft = (either: Either): either is Left => { - return either.tag === 'Left'; -}; +type Right = { tag: 'Right'; value: Record }; +type Either = Left | Right; +const isLeft = (either: Either): either is Left => either.tag === 'Left'; +const isRight = (either: Either): either is Right => either.tag === 'Right'; export interface SavedObjectsRepositoryOptions { index: string; @@ -220,8 +214,8 @@ export class SavedObjectsRepository { const { id, migrationVersion, - overwrite = false, namespace, + overwrite = false, references = [], refresh = DEFAULT_REFRESH_SETTING, } = options; @@ -230,22 +224,36 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); } - const method = id && !overwrite ? 'create' : 'index'; const time = this._getCurrentTime(); + let savedObjectNamespace; + let savedObjectNamespaces: string[] | undefined; + + if (this._registry.isSingleNamespace(type) && namespace) { + savedObjectNamespace = namespace; + } else if (this._registry.isMultiNamespace(type)) { + if (id && overwrite) { + // we will overwrite a multi-namespace saved object if it exists; if that happens, ensure we preserve its included namespaces + savedObjectNamespaces = await this.preflightGetNamespaces(type, id, namespace); + } else { + savedObjectNamespaces = getSavedObjectNamespaces(namespace); + } + } try { const migrated = this._migrator.migrateDocument({ id, type, - namespace, + ...(savedObjectNamespace && { namespace: savedObjectNamespace }), + ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), attributes, migrationVersion, updated_at: time, - references, + ...(Array.isArray(references) && { references }), }); const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); + const method = id && overwrite ? 'index' : 'create'; const response = await this._writeToCluster(method, { id: raw._id, index: this.getIndexForType(type), @@ -282,10 +290,9 @@ export class SavedObjectsRepository { ): Promise> { const { namespace, overwrite = false, refresh = DEFAULT_REFRESH_SETTING } = options; const time = this._getCurrentTime(); - const bulkCreateParams: object[] = []; - let requestIndexCounter = 0; - const expectedResults: Array> = objects.map(object => { + let bulkGetRequestIndexCounter = 0; + const expectedResults: Either[] = objects.map(object => { if (!this._allowedTypes.includes(object.type)) { return { tag: 'Left' as 'Left', @@ -297,9 +304,73 @@ export class SavedObjectsRepository { }; } - const method = object.id && !overwrite ? 'create' : 'index'; + const method = object.id && overwrite ? 'index' : 'create'; + const requiresNamespacesCheck = + method === 'index' && this._registry.isMultiNamespace(object.type); + + return { + tag: 'Right' as 'Right', + value: { + method, + object, + ...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }), + }, + }; + }); + + const bulkGetDocs = expectedResults + .filter(isRight) + .filter(({ value }) => value.esRequestIndex !== undefined) + .map(({ value: { object: { type, id } } }) => ({ + _id: this._serializer.generateRawId(namespace, type, id), + _index: this.getIndexForType(type), + _source: ['type', 'namespaces'], + })); + const bulkGetResponse = bulkGetDocs.length + ? await this._callCluster('mget', { + body: { + docs: bulkGetDocs, + }, + ignore: [404], + }) + : undefined; + + let bulkRequestIndexCounter = 0; + const bulkCreateParams: object[] = []; + const expectedBulkResults: Either[] = expectedResults.map(expectedBulkGetResult => { + if (isLeft(expectedBulkGetResult)) { + return expectedBulkGetResult; + } + + let savedObjectNamespace; + let savedObjectNamespaces; + const { esRequestIndex, object, method } = expectedBulkGetResult.value; + if (esRequestIndex !== undefined) { + const indexFound = bulkGetResponse.status !== 404; + const actualResult = indexFound ? bulkGetResponse.docs[esRequestIndex] : undefined; + const docFound = indexFound && actualResult.found === true; + if (docFound && !this.rawDocExistsInNamespace(actualResult, namespace)) { + const { id, type } = object; + return { + tag: 'Left' as 'Left', + error: { + id, + type, + error: SavedObjectsErrorHelpers.createConflictError(type, id).output.payload, + }, + }; + } + savedObjectNamespaces = getSavedObjectNamespaces(namespace, docFound && actualResult); + } else { + if (this._registry.isSingleNamespace(object.type)) { + savedObjectNamespace = namespace; + } else if (this._registry.isMultiNamespace(object.type)) { + savedObjectNamespaces = getSavedObjectNamespaces(namespace); + } + } + const expectedResult = { - esRequestIndex: requestIndexCounter++, + esRequestIndex: bulkRequestIndexCounter++, requestedId: object.id, rawMigratedDoc: this._serializer.savedObjectToRaw( this._migrator.migrateDocument({ @@ -307,7 +378,8 @@ export class SavedObjectsRepository { type: object.type, attributes: object.attributes, migrationVersion: object.migrationVersion, - namespace, + ...(savedObjectNamespace && { namespace: savedObjectNamespace }), + ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), updated_at: time, references: object.references || [], }) as SavedObjectSanitizedDoc @@ -327,19 +399,21 @@ export class SavedObjectsRepository { return { tag: 'Right' as 'Right', value: expectedResult }; }); - const esResponse = await this._writeToCluster('bulk', { - refresh, - body: bulkCreateParams, - }); + const bulkResponse = bulkCreateParams.length + ? await this._writeToCluster('bulk', { + refresh, + body: bulkCreateParams, + }) + : undefined; return { - saved_objects: expectedResults.map(expectedResult => { + saved_objects: expectedBulkResults.map(expectedResult => { if (isLeft(expectedResult)) { - return expectedResult.error; + return expectedResult.error as any; } const { requestedId, rawMigratedDoc, esRequestIndex } = expectedResult.value; - const response = esResponse.items[esRequestIndex]; + const response = bulkResponse.items[esRequestIndex]; const { error, _id: responseId, @@ -348,7 +422,7 @@ export class SavedObjectsRepository { } = Object.values(response)[0] as any; const { - _source: { type, [type]: attributes, references = [] }, + _source: { type, [type]: attributes, references = [], namespaces }, } = rawMigratedDoc; const id = requestedId || responseId; @@ -362,6 +436,7 @@ export class SavedObjectsRepository { return { id, type, + ...(namespaces && { namespaces }), updated_at: time, version: encodeVersion(seqNo, primaryTerm), attributes, @@ -382,32 +457,76 @@ export class SavedObjectsRepository { */ async delete(type: string, id: string, options: SavedObjectsDeleteOptions = {}): Promise<{}> { if (!this._allowedTypes.includes(type)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(); + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } const { namespace, refresh = DEFAULT_REFRESH_SETTING } = options; - const response = await this._writeToCluster('delete', { - id: this._serializer.generateRawId(namespace, type, id), + const rawId = this._serializer.generateRawId(namespace, type, id); + let preflightResult: SavedObjectsRawDoc | undefined; + + if (this._registry.isMultiNamespace(type)) { + preflightResult = await this.preflightCheckIncludesNamespace(type, id, namespace); + const existingNamespaces = getSavedObjectNamespaces(undefined, preflightResult); + const remainingNamespaces = existingNamespaces?.filter( + x => x !== getNamespaceString(namespace) + ); + + if (remainingNamespaces?.length) { + // if there is 1 or more namespace remaining, update the saved object + const time = this._getCurrentTime(); + + const doc = { + updated_at: time, + namespaces: remainingNamespaces, + }; + + const updateResponse = await this._writeToCluster('update', { + id: rawId, + index: this.getIndexForType(type), + ...getExpectedVersionProperties(undefined, preflightResult), + refresh, + ignore: [404], + body: { + doc, + }, + }); + + if (updateResponse.status === 404) { + // see "404s from missing index" above + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return {}; + } + } + + const deleteResponse = await this._writeToCluster('delete', { + id: rawId, index: this.getIndexForType(type), + ...getExpectedVersionProperties(undefined, preflightResult), refresh, ignore: [404], }); - const deleted = response.result === 'deleted'; + const deleted = deleteResponse.result === 'deleted'; if (deleted) { return {}; } - const docNotFound = response.result === 'not_found'; - const indexNotFound = response.error && response.error.type === 'index_not_found_exception'; - if (docNotFound || indexNotFound) { + const deleteDocNotFound = deleteResponse.result === 'not_found'; + const deleteIndexNotFound = + deleteResponse.error && deleteResponse.error.type === 'index_not_found_exception'; + if (deleteDocNotFound || deleteIndexNotFound) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } throw new Error( - `Unexpected Elasticsearch DELETE response: ${JSON.stringify({ type, id, response })}` + `Unexpected Elasticsearch DELETE response: ${JSON.stringify({ + type, + id, + response: deleteResponse, + })}` ); } @@ -426,25 +545,37 @@ export class SavedObjectsRepository { } const { refresh = DEFAULT_REFRESH_SETTING } = options; - const allTypes = Object.keys(getRootPropertiesObjects(this._mappings)); + const typesToUpdate = allTypes.filter(type => !this._registry.isNamespaceAgnostic(type)); - const typesToDelete = allTypes.filter(type => !this._registry.isNamespaceAgnostic(type)); - - const esOptions = { - index: this.getIndicesForTypes(typesToDelete), + const updateOptions = { + index: this.getIndicesForTypes(typesToUpdate), ignore: [404], refresh, body: { + script: { + source: ` + if (!ctx._source.containsKey('namespaces')) { + ctx.op = "delete"; + } else { + ctx._source['namespaces'].removeAll(Collections.singleton(params['namespace'])); + if (ctx._source['namespaces'].empty) { + ctx.op = "delete"; + } + } + `, + lang: 'painless', + params: { namespace: getNamespaceString(namespace) }, + }, conflicts: 'proceed', ...getSearchDsl(this._mappings, this._registry, { namespace, - type: typesToDelete, + type: typesToUpdate, }), }, }; - return await this._writeToCluster('deleteByQuery', esOptions); + return await this._writeToCluster('updateByQuery', updateOptions); } /** @@ -586,55 +717,77 @@ export class SavedObjectsRepository { return { saved_objects: [] }; } - const unsupportedTypeObjects = objects - .filter(o => !this._allowedTypes.includes(o.type)) - .map(({ type, id }) => { - return ({ - id, - type, - error: SavedObjectsErrorHelpers.createUnsupportedTypeError(type).output.payload, - } as any) as SavedObject; - }); + let bulkGetRequestIndexCounter = 0; + const expectedBulkGetResults: Either[] = objects.map(object => { + const { type, id, fields } = object; - const supportedTypeObjects = objects.filter(o => this._allowedTypes.includes(o.type)); + if (!this._allowedTypes.includes(type)) { + return { + tag: 'Left' as 'Left', + error: { + id, + type, + error: SavedObjectsErrorHelpers.createUnsupportedTypeError(type).output.payload, + }, + }; + } - const response = await this._callCluster('mget', { - body: { - docs: supportedTypeObjects.map(({ type, id, fields }) => { - return { - _id: this._serializer.generateRawId(namespace, type, id), - _index: this.getIndexForType(type), - _source: includedFields(type, fields), - }; - }), - }, + return { + tag: 'Right' as 'Right', + value: { + type, + id, + fields, + esRequestIndex: bulkGetRequestIndexCounter++, + }, + }; }); + const bulkGetDocs = expectedBulkGetResults + .filter(isRight) + .map(({ value: { type, id, fields } }) => ({ + _id: this._serializer.generateRawId(namespace, type, id), + _index: this.getIndexForType(type), + _source: includedFields(type, fields), + })); + const bulkGetResponse = bulkGetDocs.length + ? await this._callCluster('mget', { + body: { + docs: bulkGetDocs, + }, + ignore: [404], + }) + : undefined; + return { - saved_objects: (response.docs as any[]) - .map((doc, i) => { - const { id, type } = supportedTypeObjects[i]; + saved_objects: expectedBulkGetResults.map(expectedResult => { + if (isLeft(expectedResult)) { + return expectedResult.error as any; + } - if (!doc.found) { - return ({ - id, - type, - error: { statusCode: 404, message: 'Not found' }, - } as any) as SavedObject; - } + const { type, id, esRequestIndex } = expectedResult.value; + const doc = bulkGetResponse.docs[esRequestIndex]; - const time = doc._source.updated_at; - return { + if (!doc.found || !this.rawDocExistsInNamespace(doc, namespace)) { + return ({ id, type, - ...(time && { updated_at: time }), - version: encodeHitVersion(doc), - attributes: doc._source[type], - references: doc._source.references || [], - migrationVersion: doc._source.migrationVersion, - }; - }) - .concat(unsupportedTypeObjects), + error: SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload, + } as any) as SavedObject; + } + + const time = doc._source.updated_at; + return { + id, + type, + ...(doc._source.namespaces && { namespaces: doc._source.namespaces }), + ...(time && { updated_at: time }), + version: encodeHitVersion(doc), + attributes: doc._source[type], + references: doc._source.references || [], + migrationVersion: doc._source.migrationVersion, + }; + }), }; } @@ -666,7 +819,7 @@ export class SavedObjectsRepository { const docNotFound = response.found === false; const indexNotFound = response.status === 404; - if (docNotFound || indexNotFound) { + if (docNotFound || indexNotFound || !this.rawDocExistsInNamespace(response, namespace)) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } @@ -676,6 +829,7 @@ export class SavedObjectsRepository { return { id, type, + ...(response._source.namespaces && { namespaces: response._source.namespaces }), ...(updatedAt && { updated_at: updatedAt }), version: encodeHitVersion(response), attributes: response._source[type], @@ -707,29 +861,32 @@ export class SavedObjectsRepository { const { version, namespace, references, refresh = DEFAULT_REFRESH_SETTING } = options; + let preflightResult: SavedObjectsRawDoc | undefined; + if (this._registry.isMultiNamespace(type)) { + preflightResult = await this.preflightCheckIncludesNamespace(type, id, namespace); + } + const time = this._getCurrentTime(); const doc = { [type]: attributes, updated_at: time, - references, + ...(Array.isArray(references) && { references }), }; - if (!Array.isArray(doc.references)) { - delete doc.references; - } - const response = await this._writeToCluster('update', { + const updateResponse = await this._writeToCluster('update', { id: this._serializer.generateRawId(namespace, type, id), index: this.getIndexForType(type), - ...(version && decodeRequestVersion(version)), + ...getExpectedVersionProperties(version, preflightResult), refresh, ignore: [404], body: { doc, }, + ...(this._registry.isMultiNamespace(type) && { _sourceIncludes: ['namespaces'] }), }); - if (response.status === 404) { + if (updateResponse.status === 404) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } @@ -738,12 +895,168 @@ export class SavedObjectsRepository { id, type, updated_at: time, - version: encodeHitVersion(response), + version: encodeHitVersion(updateResponse), + ...(this._registry.isMultiNamespace(type) && { + namespaces: updateResponse.get._source.namespaces, + }), references, attributes, }; } + /** + * Adds one or more namespaces to a given multi-namespace saved object. This method and + * [`deleteFromNamespaces`]{@link SavedObjectsRepository.deleteFromNamespaces} are the only ways to change which Spaces a multi-namespace + * saved object is shared to. + */ + async addToNamespaces( + type: string, + id: string, + namespaces: string[], + options: SavedObjectsAddToNamespacesOptions = {} + ): Promise<{}> { + if (!this._allowedTypes.includes(type)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + + if (!this._registry.isMultiNamespace(type)) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `${type} doesn't support multiple namespaces` + ); + } + + if (!namespaces.length) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'namespaces must be a non-empty array of strings' + ); + } + + const { version, namespace, refresh = DEFAULT_REFRESH_SETTING } = options; + + const rawId = this._serializer.generateRawId(undefined, type, id); + const preflightResult = await this.preflightCheckIncludesNamespace(type, id, namespace); + const existingNamespaces = getSavedObjectNamespaces(undefined, preflightResult); + // there should never be a case where a multi-namespace object does not have any existing namespaces + // however, it is a possibility if someone manually modifies the document in Elasticsearch + const time = this._getCurrentTime(); + + const doc = { + updated_at: time, + namespaces: existingNamespaces ? unique(existingNamespaces.concat(namespaces)) : namespaces, + }; + + const updateResponse = await this._writeToCluster('update', { + id: rawId, + index: this.getIndexForType(type), + ...getExpectedVersionProperties(version, preflightResult), + refresh, + ignore: [404], + body: { + doc, + }, + }); + + if (updateResponse.status === 404) { + // see "404s from missing index" above + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + + return {}; + } + + /** + * Removes one or more namespaces from a given multi-namespace saved object. If no namespaces remain, the saved object is deleted + * entirely. This method and [`addToNamespaces`]{@link SavedObjectsRepository.addToNamespaces} are the only ways to change which Spaces a + * multi-namespace saved object is shared to. + */ + async deleteFromNamespaces( + type: string, + id: string, + namespaces: string[], + options: SavedObjectsDeleteFromNamespacesOptions = {} + ): Promise<{}> { + if (!this._allowedTypes.includes(type)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + + if (!this._registry.isMultiNamespace(type)) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `${type} doesn't support multiple namespaces` + ); + } + + if (!namespaces.length) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'namespaces must be a non-empty array of strings' + ); + } + + const { namespace, refresh = DEFAULT_REFRESH_SETTING } = options; + + const rawId = this._serializer.generateRawId(undefined, type, id); + const preflightResult = await this.preflightCheckIncludesNamespace(type, id, namespace); + const existingNamespaces = getSavedObjectNamespaces(undefined, preflightResult); + // if there are somehow no existing namespaces, allow the operation to proceed and delete this saved object + const remainingNamespaces = existingNamespaces?.filter(x => !namespaces.includes(x)); + + if (remainingNamespaces?.length) { + // if there is 1 or more namespace remaining, update the saved object + const time = this._getCurrentTime(); + + const doc = { + updated_at: time, + namespaces: remainingNamespaces, + }; + + const updateResponse = await this._writeToCluster('update', { + id: rawId, + index: this.getIndexForType(type), + ...getExpectedVersionProperties(undefined, preflightResult), + refresh, + ignore: [404], + body: { + doc, + }, + }); + + if (updateResponse.status === 404) { + // see "404s from missing index" above + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return {}; + } else { + // if there are no namespaces remaining, delete the saved object + const deleteResponse = await this._writeToCluster('delete', { + id: this._serializer.generateRawId(undefined, type, id), + index: this.getIndexForType(type), + ...getExpectedVersionProperties(undefined, preflightResult), + refresh, + ignore: [404], + }); + + const deleted = deleteResponse.result === 'deleted'; + if (deleted) { + return {}; + } + + const deleteDocNotFound = deleteResponse.result === 'not_found'; + const deleteIndexNotFound = + deleteResponse.error && deleteResponse.error.type === 'index_not_found_exception'; + if (deleteDocNotFound || deleteIndexNotFound) { + // see "404s from missing index" above + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + + throw new Error( + `Unexpected Elasticsearch DELETE response: ${JSON.stringify({ + type, + id, + response: deleteResponse, + })}` + ); + } + } + /** * Updates multiple objects in bulk * @@ -757,10 +1070,10 @@ export class SavedObjectsRepository { options: SavedObjectsBulkUpdateOptions = {} ): Promise> { const time = this._getCurrentTime(); - const bulkUpdateParams: object[] = []; + const { namespace } = options; - let requestIndexCounter = 0; - const expectedResults: Array> = objects.map(object => { + let bulkGetRequestIndexCounter = 0; + const expectedBulkGetResults: Either[] = objects.map(object => { const { type, id } = object; if (!this._allowedTypes.includes(type)) { @@ -775,41 +1088,100 @@ export class SavedObjectsRepository { } const { attributes, references, version } = object; - const { namespace } = options; const documentToSave = { [type]: attributes, updated_at: time, - references, + ...(Array.isArray(references) && { references }), }; - if (!Array.isArray(documentToSave.references)) { - delete documentToSave.references; - } + const requiresNamespacesCheck = this._registry.isMultiNamespace(object.type); - const expectedResult = { - type, - id, - esRequestIndex: requestIndexCounter++, - documentToSave, + return { + tag: 'Right' as 'Right', + value: { + type, + id, + version, + documentToSave, + ...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }), + }, }; + }); - bulkUpdateParams.push( - { - update: { - _id: this._serializer.generateRawId(namespace, type, id), - _index: this.getIndexForType(type), - ...(version && decodeRequestVersion(version)), + const bulkGetDocs = expectedBulkGetResults + .filter(isRight) + .filter(({ value }) => value.esRequestIndex !== undefined) + .map(({ value: { type, id } }) => ({ + _id: this._serializer.generateRawId(namespace, type, id), + _index: this.getIndexForType(type), + _source: ['type', 'namespaces'], + })); + const bulkGetResponse = bulkGetDocs.length + ? await this._callCluster('mget', { + body: { + docs: bulkGetDocs, }, - }, - { doc: documentToSave } - ); + ignore: [404], + }) + : undefined; - return { tag: 'Right' as 'Right', value: expectedResult }; - }); + let bulkUpdateRequestIndexCounter = 0; + const bulkUpdateParams: object[] = []; + const expectedBulkUpdateResults: Either[] = expectedBulkGetResults.map( + expectedBulkGetResult => { + if (isLeft(expectedBulkGetResult)) { + return expectedBulkGetResult; + } + + const { esRequestIndex, id, type, version, documentToSave } = expectedBulkGetResult.value; + let namespaces; + let versionProperties; + if (esRequestIndex !== undefined) { + const indexFound = bulkGetResponse.status !== 404; + const actualResult = indexFound ? bulkGetResponse.docs[esRequestIndex] : undefined; + const docFound = indexFound && actualResult.found === true; + if (!docFound || !this.rawDocExistsInNamespace(actualResult, namespace)) { + return { + tag: 'Left' as 'Left', + error: { + id, + type, + error: SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload, + }, + }; + } + namespaces = actualResult._source.namespaces; + versionProperties = getExpectedVersionProperties(version, actualResult); + } else { + versionProperties = getExpectedVersionProperties(version); + } + + const expectedResult = { + type, + id, + namespaces, + esRequestIndex: bulkUpdateRequestIndexCounter++, + documentToSave: expectedBulkGetResult.value.documentToSave, + }; + + bulkUpdateParams.push( + { + update: { + _id: this._serializer.generateRawId(namespace, type, id), + _index: this.getIndexForType(type), + ...versionProperties, + }, + }, + { doc: documentToSave } + ); + + return { tag: 'Right' as 'Right', value: expectedResult }; + } + ); const { refresh = DEFAULT_REFRESH_SETTING } = options; - const esResponse = bulkUpdateParams.length + const bulkUpdateResponse = bulkUpdateParams.length ? await this._writeToCluster('bulk', { refresh, body: bulkUpdateParams, @@ -817,13 +1189,13 @@ export class SavedObjectsRepository { : {}; return { - saved_objects: expectedResults.map(expectedResult => { + saved_objects: expectedBulkUpdateResults.map(expectedResult => { if (isLeft(expectedResult)) { - return expectedResult.error; + return expectedResult.error as any; } - const { type, id, documentToSave, esRequestIndex } = expectedResult.value; - const response = esResponse.items[esRequestIndex]; + const { type, id, namespaces, documentToSave, esRequestIndex } = expectedResult.value; + const response = bulkUpdateResponse.items[esRequestIndex]; const { error, _seq_no: seqNo, _primary_term: primaryTerm } = Object.values( response )[0] as any; @@ -839,6 +1211,7 @@ export class SavedObjectsRepository { return { id, type, + ...(namespaces && { namespaces }), updated_at, version: encodeVersion(seqNo, primaryTerm), attributes, @@ -877,10 +1250,20 @@ export class SavedObjectsRepository { const { migrationVersion, namespace, refresh = DEFAULT_REFRESH_SETTING } = options; const time = this._getCurrentTime(); + let savedObjectNamespace; + let savedObjectNamespaces: string[] | undefined; + + if (this._registry.isSingleNamespace(type) && namespace) { + savedObjectNamespace = namespace; + } else if (this._registry.isMultiNamespace(type)) { + savedObjectNamespaces = await this.preflightGetNamespaces(type, id, namespace); + } const migrated = this._migrator.migrateDocument({ id, type, + ...(savedObjectNamespace && { namespace: savedObjectNamespace }), + ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), attributes: { [counterFieldName]: 1 }, migrationVersion, updated_at: time, @@ -889,7 +1272,7 @@ export class SavedObjectsRepository { const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); const response = await this._writeToCluster('update', { - id: this._serializer.generateRawId(namespace, type, id), + id: raw._id, index: this.getIndexForType(type), refresh, _source: true, @@ -952,14 +1335,13 @@ export class SavedObjectsRepository { } /** - * Returns an array of indices as specified in `this._schema` for each of the + * Returns an array of indices as specified in `this._registry` for each of the * given `types`. If any of the types don't have an associated index, the * default index `this._index` will be included. * * @param types The types whose indices should be retrieved */ private getIndicesForTypes(types: string[]) { - const unique = (array: string[]) => [...new Set(array)]; return unique(types.map(t => this.getIndexForType(t))); } @@ -975,12 +1357,97 @@ export class SavedObjectsRepository { const savedObject = this._serializer.rawToSavedObject(raw); return omit(savedObject, 'namespace'); } + + /** + * Check to ensure that a raw document exists in a namespace. If the document is not a multi-namespace type, then this returns `true` as + * we rely on the guarantees of the document ID format. If the document is a multi-namespace type, this checks to ensure that the + * document's `namespaces` value includes the string representation of the given namespace. + * + * WARNING: This should only be used for documents that were retrieved from Elasticsearch. Otherwise, the guarantees of the document ID + * format mentioned above do not apply. + */ + private rawDocExistsInNamespace(raw: SavedObjectsRawDoc, namespace?: string) { + const rawDocType = raw._source.type; + + // if the type is namespace isolated, or namespace agnostic, we can continue to rely on the guarantees + // of the document ID format and don't need to check this + if (!this._registry.isMultiNamespace(rawDocType)) { + return true; + } + + const namespaces = raw._source.namespaces; + return namespaces?.includes(getNamespaceString(namespace)) ?? false; + } + + /** + * Pre-flight check to get a multi-namespace saved object's included namespaces. This ensures that, if the saved object exists, it + * includes the target namespace. + * + * @param type The type of the saved object. + * @param id The ID of the saved object. + * @param namespace The target namespace. + * @returns Array of namespaces that this saved object currently includes, or (if the object does not exist yet) the namespaces that a + * newly-created object will include. Value may be undefined if an existing saved object has no namespaces attribute; this should not + * happen in normal operations, but it is possible if the Elasticsearch document is manually modified. + * @throws Will throw an error if the saved object exists and it does not include the target namespace. + */ + private async preflightGetNamespaces(type: string, id: string, namespace?: string) { + if (!this._registry.isMultiNamespace(type)) { + throw new Error(`Cannot make preflight get request for non-multi-namespace type '${type}'.`); + } + + const response = await this._callCluster('get', { + id: this._serializer.generateRawId(undefined, type, id), + index: this.getIndexForType(type), + ignore: [404], + }); + + const indexFound = response.status !== 404; + const docFound = indexFound && response.found === true; + if (docFound) { + if (!this.rawDocExistsInNamespace(response, namespace)) { + throw SavedObjectsErrorHelpers.createConflictError(type, id); + } + return getSavedObjectNamespaces(namespace, response); + } + return getSavedObjectNamespaces(namespace); + } + + /** + * Pre-flight check for a multi-namespace saved object's namespaces. This ensures that, if the saved object exists, it includes the target + * namespace. + * + * @param type The type of the saved object. + * @param id The ID of the saved object. + * @param namespace The target namespace. + * @returns Raw document from Elasticsearch. + * @throws Will throw an error if the saved object is not found, or if it doesn't include the target namespace. + */ + private async preflightCheckIncludesNamespace(type: string, id: string, namespace?: string) { + if (!this._registry.isMultiNamespace(type)) { + throw new Error(`Cannot make preflight get request for non-multi-namespace type '${type}'.`); + } + + const rawId = this._serializer.generateRawId(undefined, type, id); + const response = await this._callCluster('get', { + id: rawId, + index: this.getIndexForType(type), + ignore: [404], + }); + + const indexFound = response.status !== 404; + const docFound = indexFound && response.found === true; + if (!docFound || !this.rawDocExistsInNamespace(response, namespace)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return response as SavedObjectsRawDoc; + } } 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' }; + return SavedObjectsErrorHelpers.createConflictError(type, id).output.payload; case 'document_missing_exception': return SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output.payload; default: @@ -989,3 +1456,49 @@ function getBulkOperationError(error: { type: string; reason?: string }, type: s }; } } + +/** + * Returns an object with the expected version properties. This facilitates Elasticsearch's Optimistic Concurrency Control. + * + * @param version Optional version specified by the consumer. + * @param document Optional existing document that was obtained in a preflight operation. + */ +function getExpectedVersionProperties(version?: string, document?: SavedObjectsRawDoc) { + if (version) { + return decodeRequestVersion(version); + } else if (document) { + return { + if_seq_no: document._seq_no, + if_primary_term: document._primary_term, + }; + } + 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 + * operations, but it is possible if the Elasticsearch document is manually modified. + * + * @param namespace The current namespace. + * @param document Optional existing saved object that was obtained in a preflight operation. + */ +function getSavedObjectNamespaces( + namespace?: string, + document?: SavedObjectsRawDoc +): string[] | undefined { + if (document) { + return document._source?.namespaces; + } + return [getNamespaceString(namespace)]; +} + +const unique = (array: string[]) => [...new Set(array)]; diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index b2129765ee4264..a72c21dd5eee41 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -17,6 +17,9 @@ * under the License. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { esKuery, KueryNode } from '../../../../../../plugins/data/server'; + import { typeRegistryMock } from '../../../saved_objects_type_registry.mock'; import { getQueryParams } from './query_params'; @@ -24,1268 +27,329 @@ const registry = typeRegistryMock.create(); const MAPPINGS = { properties: { - type: { - type: 'keyword', - }, - pending: { - properties: { - title: { - type: 'text', - }, - }, - }, + pending: { properties: { title: { type: 'text' } } }, saved: { properties: { - title: { - type: 'text', - fields: { - raw: { - type: 'keyword', - }, - }, - }, - obj: { - properties: { - key1: { - type: 'text', - }, - }, - }, - }, - }, - global: { - properties: { - name: { - type: 'keyword', - }, + title: { type: 'text', fields: { raw: { type: 'keyword' } } }, + obj: { properties: { key1: { type: 'text' } } }, }, }, + // mock registry returns isMultiNamespace=true for 'shared' type + shared: { properties: { name: { type: 'keyword' } } }, + // mock registry returns isNamespaceAgnostic=true for 'global' type + global: { properties: { name: { type: 'keyword' } } }, }, }; +const ALL_TYPES = Object.keys(MAPPINGS.properties); +// get all possible subsets (combination) of all types +const ALL_TYPE_SUBSETS = ALL_TYPES.reduce( + (subsets, value) => subsets.concat(subsets.map(set => [...set, value])), + [[] as string[]] +) + .filter(x => x.length) // exclude empty set + .map(x => (x.length === 1 ? x[0] : x)); // if a subset is a single string, destructure it -// create a type clause to be used within the "should", if a namespace is specified -// the clause will ensure the namespace matches; otherwise, the clause will ensure -// that there isn't a namespace field. -const createTypeClause = (type: string, namespace?: string) => { - if (namespace) { - return { - bool: { - must: [{ term: { type } }, { term: { namespace } }], - }, - }; - } +/** + * Note: these tests cases are defined in the order they appear in the source code, for readability's sake + */ +describe('#getQueryParams', () => { + const mappings = MAPPINGS; + type Result = ReturnType; - return { - bool: { - must: [{ term: { type } }], - must_not: [{ exists: { field: 'namespace' } }], - }, - }; -}; + describe('kueryNode filter clause (query.bool.filter[...]', () => { + const expectResult = (result: Result, expected: any) => { + expect(result.query.bool.filter).toEqual(expect.arrayContaining([expected])); + }; -describe('searchDsl/queryParams', () => { - describe('no parameters', () => { - it('searches for all known types without a namespace specified', () => { - expect(getQueryParams({ mappings: MAPPINGS, registry })).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending'), - createTypeClause('saved'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, - }, - ], - }, - }, + describe('`kueryNode` parameter', () => { + it('does not include the clause when `kueryNode` is not specified', () => { + const result = getQueryParams({ mappings, registry, kueryNode: undefined }); + expect(result.query.bool.filter).toHaveLength(1); }); - }); - }); - describe('namespace', () => { - it('filters namespaced types for namespace, and ensures namespace agnostic types have no namespace', () => { - expect(getQueryParams({ mappings: MAPPINGS, registry, namespace: 'foo-namespace' })).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending', 'foo-namespace'), - createTypeClause('saved', 'foo-namespace'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, - }, - ], - }, - }, - }); - }); - }); + it('includes the specified Kuery clause', () => { + const test = (kueryNode: KueryNode) => { + const result = getQueryParams({ mappings, registry, kueryNode }); + const expected = esKuery.toElasticsearchQuery(kueryNode); + expect(result.query.bool.filter).toHaveLength(2); + expectResult(result, expected); + }; - describe('type (singular, namespaced)', () => { - it('includes a terms filter for type and namespace not being specified', () => { - expect( - getQueryParams({ mappings: MAPPINGS, registry, namespace: undefined, type: 'saved' }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved')], - minimum_should_match: 1, - }, - }, - ], - }, - }, - }); - }); - }); + const simpleClause = { + type: 'function', + function: 'is', + arguments: [ + { type: 'literal', value: 'global.name' }, + { type: 'literal', value: 'GLOBAL' }, + { type: 'literal', value: false }, + ], + } as KueryNode; + test(simpleClause); - describe('type (singular, global)', () => { - it('includes a terms filter for type and namespace not being specified', () => { - expect( - getQueryParams({ mappings: MAPPINGS, registry, namespace: undefined, type: 'global' }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('global')], - minimum_should_match: 1, + const complexClause = { + type: 'function', + function: 'and', + arguments: [ + simpleClause, + { + type: 'function', + function: 'not', + arguments: [ + { + type: 'function', + function: 'is', + arguments: [ + { type: 'literal', value: 'saved.obj.key1' }, + { type: 'literal', value: 'key' }, + { type: 'literal', value: true }, + ], }, - }, - ], - }, - }, + ], + }, + ], + } as KueryNode; + test(complexClause); }); }); }); - describe('type (plural, namespaced and global)', () => { - it('includes term filters for types and namespace not being specified', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: undefined, - type: ['saved', 'global'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - }, - }, - }); - }); - }); + describe('reference filter clause (query.bool.filter[bool.must])', () => { + describe('`hasReference` parameter', () => { + const expectResult = (result: Result, expected: any) => { + expect(result.query.bool.filter).toEqual( + expect.arrayContaining([{ bool: expect.objectContaining({ must: expected }) }]) + ); + }; - describe('namespace, type (plural, namespaced and global)', () => { - it('includes a terms filter for type and namespace not being specified', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, + it('does not include the clause when `hasReference` is not specified', () => { + const result = getQueryParams({ + mappings, registry, - namespace: 'foo-namespace', - type: ['saved', 'global'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved', 'foo-namespace'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - }, - }, + hasReference: undefined, + }); + expectResult(result, undefined); }); - }); - }); - describe('search', () => { - it('includes a sqs query and all known types without a namespace specified', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, + it('creates a clause with query for specified reference', () => { + const hasReference = { id: 'foo', type: 'bar' }; + const result = getQueryParams({ + mappings, registry, - namespace: undefined, - type: undefined, - search: 'us*', - }) - ).toEqual({ - query: { - bool: { - filter: [ - { + hasReference, + }); + expectResult(result, [ + { + nested: { + path: 'references', + query: { bool: { - should: [ - createTypeClause('pending'), - createTypeClause('saved'), - createTypeClause('global'), + must: [ + { term: { 'references.id': hasReference.id } }, + { term: { 'references.type': hasReference.type } }, ], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'us*', - lenient: true, - fields: ['*'], }, }, - ], + }, }, - }, + ]); }); }); }); - describe('namespace, search', () => { - it('includes a sqs query and namespaced types with the namespace and global types without a namespace', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: 'foo-namespace', - type: undefined, - search: 'us*', - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending', 'foo-namespace'), - createTypeClause('saved', 'foo-namespace'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'us*', - lenient: true, - fields: ['*'], - }, - }, - ], - }, - }, - }); - }); - }); + describe('type filter clauses (query.bool.filter[bool.should])', () => { + describe('`type` parameter', () => { + const expectResult = (result: Result, ...types: string[]) => { + expect(result.query.bool.filter).toEqual( + expect.arrayContaining([ + { + bool: expect.objectContaining({ + should: types.map(type => ({ + bool: expect.objectContaining({ + must: expect.arrayContaining([{ term: { type } }]), + }), + })), + minimum_should_match: 1, + }), + }, + ]) + ); + }; - describe('type (plural, namespaced and global), search', () => { - it('includes a sqs query and types without a namespace', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: undefined, - type: ['saved', 'global'], - search: 'us*', - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'us*', - lenient: true, - fields: ['*'], - }, - }, - ], - }, - }, + it('searches for all known types when `type` is not specified', () => { + const result = getQueryParams({ mappings, registry, type: undefined }); + expectResult(result, ...ALL_TYPES); }); - }); - }); - describe('namespace, type (plural, namespaced and global), search', () => { - it('includes a sqs query and namespace type with a namespace and global type without a namespace', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: 'foo-namespace', - type: ['saved', 'global'], - search: 'us*', - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved', 'foo-namespace'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'us*', - lenient: true, - fields: ['*'], - }, - }, - ], - }, - }, + it('searches for specified type/s', () => { + const test = (typeOrTypes: string | string[]) => { + const result = getQueryParams({ + mappings, + registry, + type: typeOrTypes, + }); + const type = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; + expectResult(result, ...type); + }; + for (const typeOrTypes of ALL_TYPE_SUBSETS) { + test(typeOrTypes); + } }); }); - }); - describe('search, searchFields', () => { - it('includes all types for field', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: undefined, - type: undefined, - search: 'y*', - searchFields: ['title'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending'), - createTypeClause('saved'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['pending.title', 'saved.title', 'global.title'], - }, - }, - ], - }, - }, - }); - }); - it('supports field boosting', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: undefined, - type: undefined, - search: 'y*', - searchFields: ['title^3'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending'), - createTypeClause('saved'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['pending.title^3', 'saved.title^3', 'global.title^3'], - }, - }, - ], - }, - }, + describe('`namespace` parameter', () => { + const createTypeClause = (type: string, namespace?: string) => { + if (registry.isMultiNamespace(type)) { + return { + bool: { + must: expect.arrayContaining([{ term: { namespaces: namespace ?? 'default' } }]), + must_not: [{ exists: { field: 'namespace' } }], + }, + }; + } else if (namespace && registry.isSingleNamespace(type)) { + return { + bool: { + must: expect.arrayContaining([{ term: { namespace } }]), + must_not: [{ exists: { field: 'namespaces' } }], + }, + }; + } + // isNamespaceAgnostic + return { + bool: expect.objectContaining({ + must_not: [{ exists: { field: 'namespace' } }, { exists: { field: 'namespaces' } }], + }), + }; + }; + + const expectResult = (result: Result, ...typeClauses: any) => { + expect(result.query.bool.filter).toEqual( + expect.arrayContaining([ + { bool: expect.objectContaining({ should: typeClauses, minimum_should_match: 1 }) }, + ]) + ); + }; + + const test = (namespace?: string) => { + for (const typeOrTypes of ALL_TYPE_SUBSETS) { + const result = getQueryParams({ mappings, registry, type: typeOrTypes, namespace }); + const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; + expectResult(result, ...types.map(x => createTypeClause(x, namespace))); + } + // also test with no specified type/s + const result = getQueryParams({ mappings, registry, type: undefined, namespace }); + expectResult(result, ...ALL_TYPES.map(x => createTypeClause(x, namespace))); + }; + + it('filters results with "namespace" field when `namespace` is not specified', () => { + test(undefined); }); - }); - it('supports field and multi-field', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: undefined, - type: undefined, - search: 'y*', - searchFields: ['title', 'title.raw'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending'), - createTypeClause('saved'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: [ - 'pending.title', - 'saved.title', - 'global.title', - 'pending.title.raw', - 'saved.title.raw', - 'global.title.raw', - ], - }, - }, - ], - }, - }, + + it('filters results for specified namespace for appropriate type/s', () => { + test('foo-namespace'); }); }); }); - describe('namespace, search, searchFields', () => { - it('includes all types for field', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, + describe('search clause (query.bool.must.simple_query_string)', () => { + const search = 'foo*'; + + const expectResult = (result: Result, sqsClause: any) => { + expect(result.query.bool.must).toEqual([{ simple_query_string: sqsClause }]); + }; + + describe('`search` parameter', () => { + it('does not include clause when `search` is not specified', () => { + const result = getQueryParams({ + mappings, registry, - namespace: 'foo-namespace', - type: undefined, - search: 'y*', - searchFields: ['title'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending', 'foo-namespace'), - createTypeClause('saved', 'foo-namespace'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['pending.title', 'saved.title', 'global.title'], - }, - }, - ], - }, - }, + search: undefined, + }); + expect(result.query.bool.must).toBeUndefined(); }); - }); - it('supports field boosting', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, + + it('creates a clause with query for specified search', () => { + const result = getQueryParams({ + mappings, registry, - namespace: 'foo-namespace', - type: undefined, - search: 'y*', - searchFields: ['title^3'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending', 'foo-namespace'), - createTypeClause('saved', 'foo-namespace'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['pending.title^3', 'saved.title^3', 'global.title^3'], - }, - }, - ], - }, - }, + search, + }); + expectResult(result, expect.objectContaining({ query: search })); }); }); - it('supports field and multi-field', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, + + describe('`searchFields` parameter', () => { + const getExpectedFields = (searchFields: string[], typeOrTypes: string | string[]) => { + const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; + return searchFields.map(x => types.map(y => `${y}.${x}`)).flat(); + }; + + const test = (searchFields: string[]) => { + for (const typeOrTypes of ALL_TYPE_SUBSETS) { + const result = getQueryParams({ + mappings, + registry, + type: typeOrTypes, + search, + searchFields, + }); + const fields = getExpectedFields(searchFields, typeOrTypes); + expectResult(result, expect.objectContaining({ fields })); + } + // also test with no specified type/s + const result = getQueryParams({ + mappings, registry, - namespace: 'foo-namespace', type: undefined, - search: 'y*', - searchFields: ['title', 'title.raw'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - createTypeClause('pending', 'foo-namespace'), - createTypeClause('saved', 'foo-namespace'), - createTypeClause('global'), - ], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: [ - 'pending.title', - 'saved.title', - 'global.title', - 'pending.title.raw', - 'saved.title.raw', - 'global.title.raw', - ], - }, - }, - ], - }, - }, - }); - }); - }); + search, + searchFields, + }); + const fields = getExpectedFields(searchFields, ALL_TYPES); + expectResult(result, expect.objectContaining({ fields })); + }; - describe('type (plural, namespaced and global), search, searchFields', () => { - it('includes all types for field', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: undefined, - type: ['saved', 'global'], - search: 'y*', - searchFields: ['title'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['saved.title', 'global.title'], - }, - }, - ], - }, - }, - }); - }); - it('supports field boosting', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: undefined, - type: ['saved', 'global'], - search: 'y*', - searchFields: ['title^3'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['saved.title^3', 'global.title^3'], - }, - }, - ], - }, - }, - }); - }); - it('supports field and multi-field', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, + it('includes lenient flag and all fields when `searchFields` is not specified', () => { + const result = getQueryParams({ + mappings, registry, - namespace: undefined, - type: ['saved', 'global'], - search: 'y*', - searchFields: ['title', 'title.raw'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['saved.title', 'global.title', 'saved.title.raw', 'global.title.raw'], - }, - }, - ], - }, - }, + search, + searchFields: undefined, + }); + expectResult(result, expect.objectContaining({ lenient: true, fields: ['*'] })); }); - }); - }); - describe('namespace, type (plural, namespaced and global), search, searchFields', () => { - it('includes all types for field', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: 'foo-namespace', - type: ['saved', 'global'], - search: 'y*', - searchFields: ['title'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved', 'foo-namespace'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['saved.title', 'global.title'], - }, - }, - ], - }, - }, - }); - }); - it('supports field boosting', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: 'foo-namespace', - type: ['saved', 'global'], - search: 'y*', - searchFields: ['title^3'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved', 'foo-namespace'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['saved.title^3', 'global.title^3'], - }, - }, - ], - }, - }, - }); - }); - it('supports field and multi-field', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: 'foo-namespace', - type: ['saved', 'global'], - search: 'y*', - searchFields: ['title', 'title.raw'], - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [createTypeClause('saved', 'foo-namespace'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['saved.title', 'global.title', 'saved.title.raw', 'global.title.raw'], - }, - }, - ], - }, - }, + it('includes specified search fields for appropriate type/s', () => { + test(['title']); }); - }); - }); - describe('type (plural, namespaced and global), search, defaultSearchOperator', () => { - it('supports defaultSearchOperator', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: 'foo-namespace', - type: ['saved', 'global'], - search: 'foo', - searchFields: undefined, - defaultSearchOperator: 'AND', - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - minimum_should_match: 1, - should: [ - { - bool: { - must: [ - { - term: { - type: 'saved', - }, - }, - { - term: { - namespace: 'foo-namespace', - }, - }, - ], - }, - }, - { - bool: { - must: [ - { - term: { - type: 'global', - }, - }, - ], - must_not: [ - { - exists: { - field: 'namespace', - }, - }, - ], - }, - }, - ], - }, - }, - ], - must: [ - { - simple_query_string: { - lenient: true, - fields: ['*'], - default_operator: 'AND', - query: 'foo', - }, - }, - ], - }, - }, + it('supports boosting', () => { + test(['title^3']); }); - }); - }); - describe('type (plural, namespaced and global), hasReference', () => { - it('supports hasReference', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: 'foo-namespace', - type: ['saved', 'global'], - search: undefined, - searchFields: undefined, - defaultSearchOperator: 'OR', - hasReference: { - type: 'bar', - id: '1', - }, - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - must: [ - { - nested: { - path: 'references', - query: { - bool: { - must: [ - { - term: { - 'references.id': '1', - }, - }, - { - term: { - 'references.type': 'bar', - }, - }, - ], - }, - }, - }, - }, - ], - should: [createTypeClause('saved', 'foo-namespace'), createTypeClause('global')], - minimum_should_match: 1, - }, - }, - ], - }, - }, + it('supports multiple fields', () => { + test(['title, title.raw']); }); }); - }); - describe('type filter', () => { - it(' with namespace', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, - registry, - namespace: 'foo-namespace', - kueryNode: { - type: 'function', - function: 'is', - arguments: [ - { type: 'literal', value: 'global.name' }, - { type: 'literal', value: 'GLOBAL' }, - { type: 'literal', value: false }, - ], - }, - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - { - match: { - 'global.name': 'GLOBAL', - }, - }, - ], - minimum_should_match: 1, - }, - }, - { - bool: { - should: [ - { - bool: { - must: [ - { - term: { - type: 'pending', - }, - }, - { - term: { - namespace: 'foo-namespace', - }, - }, - ], - }, - }, - { - bool: { - must: [ - { - term: { - type: 'saved', - }, - }, - { - term: { - namespace: 'foo-namespace', - }, - }, - ], - }, - }, - { - bool: { - must: [ - { - term: { - type: 'global', - }, - }, - ], - must_not: [ - { - exists: { - field: 'namespace', - }, - }, - ], - }, - }, - ], - minimum_should_match: 1, - }, - }, - ], - }, - }, - }); - }); - it(' with namespace and more complex filter', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, + describe('`defaultSearchOperator` parameter', () => { + it('does not include default_operator when `defaultSearchOperator` is not specified', () => { + const result = getQueryParams({ + mappings, registry, - namespace: 'foo-namespace', - kueryNode: { - type: 'function', - function: 'and', - arguments: [ - { - type: 'function', - function: 'is', - arguments: [ - { type: 'literal', value: 'global.name' }, - { type: 'literal', value: 'GLOBAL' }, - { type: 'literal', value: false }, - ], - }, - { - type: 'function', - function: 'not', - arguments: [ - { - type: 'function', - function: 'is', - arguments: [ - { type: 'literal', value: 'saved.obj.key1' }, - { type: 'literal', value: 'key' }, - { type: 'literal', value: true }, - ], - }, - ], - }, - ], - }, - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - filter: [ - { - bool: { - should: [ - { - match: { - 'global.name': 'GLOBAL', - }, - }, - ], - minimum_should_match: 1, - }, - }, - { - bool: { - must_not: { - bool: { - should: [ - { - match_phrase: { - 'saved.obj.key1': 'key', - }, - }, - ], - minimum_should_match: 1, - }, - }, - }, - }, - ], - }, - }, - { - bool: { - should: [ - { - bool: { - must: [ - { - term: { - type: 'pending', - }, - }, - { - term: { - namespace: 'foo-namespace', - }, - }, - ], - }, - }, - { - bool: { - must: [ - { - term: { - type: 'saved', - }, - }, - { - term: { - namespace: 'foo-namespace', - }, - }, - ], - }, - }, - { - bool: { - must: [ - { - term: { - type: 'global', - }, - }, - ], - must_not: [ - { - exists: { - field: 'namespace', - }, - }, - ], - }, - }, - ], - minimum_should_match: 1, - }, - }, - ], - }, - }, + search, + defaultSearchOperator: undefined, + }); + expectResult(result, expect.not.objectContaining({ default_operator: expect.anything() })); }); - }); - it(' with search and searchFields', () => { - expect( - getQueryParams({ - mappings: MAPPINGS, + + it('includes specified default operator', () => { + const defaultSearchOperator = 'AND'; + const result = getQueryParams({ + mappings, registry, - namespace: 'foo-namespace', - search: 'y*', - searchFields: ['title'], - kueryNode: { - type: 'function', - function: 'is', - arguments: [ - { type: 'literal', value: 'global.name' }, - { type: 'literal', value: 'GLOBAL' }, - { type: 'literal', value: false }, - ], - }, - }) - ).toEqual({ - query: { - bool: { - filter: [ - { - bool: { - should: [ - { - match: { - 'global.name': 'GLOBAL', - }, - }, - ], - minimum_should_match: 1, - }, - }, - { - bool: { - should: [ - { - bool: { - must: [ - { - term: { - type: 'pending', - }, - }, - { - term: { - namespace: 'foo-namespace', - }, - }, - ], - }, - }, - { - bool: { - must: [ - { - term: { - type: 'saved', - }, - }, - { - term: { - namespace: 'foo-namespace', - }, - }, - ], - }, - }, - { - bool: { - must: [ - { - term: { - type: 'global', - }, - }, - ], - must_not: [ - { - exists: { - field: 'namespace', - }, - }, - ], - }, - }, - ], - minimum_should_match: 1, - }, - }, - ], - must: [ - { - simple_query_string: { - query: 'y*', - fields: ['pending.title', 'saved.title', 'global.title'], - }, - }, - ], - }, - }, + search, + defaultSearchOperator, + }); + expectResult(result, expect.objectContaining({ default_operator: defaultSearchOperator })); }); }); }); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index e6c06208ca1a5f..967ce8bceaf843 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -66,18 +66,26 @@ function getClauseForType( namespace: string | undefined, type: string ) { - if (namespace && !registry.isNamespaceAgnostic(type)) { + if (registry.isMultiNamespace(type)) { + return { + bool: { + must: [{ term: { type } }, { term: { namespaces: namespace ?? 'default' } }], + must_not: [{ exists: { field: 'namespace' } }], + }, + }; + } else if (namespace && registry.isSingleNamespace(type)) { return { bool: { must: [{ term: { type } }, { term: { namespace } }], + must_not: [{ exists: { field: 'namespaces' } }], }, }; } - + // isSingleNamespace in the default namespace, or isNamespaceAgnostic return { bool: { must: [{ term: { type } }], - must_not: [{ exists: { field: 'namespace' } }], + must_not: [{ exists: { field: 'namespace' } }, { exists: { field: 'namespaces' } }], }, }; } diff --git a/src/core/server/saved_objects/service/saved_objects_client.mock.ts b/src/core/server/saved_objects/service/saved_objects_client.mock.ts index c6de9fa94291c6..b209c9ca54f638 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.mock.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.mock.ts @@ -31,6 +31,8 @@ const create = () => find: jest.fn(), get: jest.fn(), update: jest.fn(), + addToNamespaces: jest.fn(), + deleteFromNamespaces: jest.fn(), } as unknown) as jest.Mocked); export const savedObjectsClientMock = { create }; diff --git a/src/core/server/saved_objects/service/saved_objects_client.test.js b/src/core/server/saved_objects/service/saved_objects_client.test.js index 1794d9ae86c171..53bb31369adbfa 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.test.js +++ b/src/core/server/saved_objects/service/saved_objects_client.test.js @@ -147,3 +147,37 @@ test(`#bulkUpdate`, async () => { }); expect(result).toBe(returnValue); }); + +test(`#addToNamespaces`, async () => { + const returnValue = Symbol(); + const mockRepository = { + addToNamespaces: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const type = Symbol(); + const id = Symbol(); + const namespaces = Symbol(); + const options = Symbol(); + const result = await client.addToNamespaces(type, id, namespaces, options); + + expect(mockRepository.addToNamespaces).toHaveBeenCalledWith(type, id, namespaces, options); + expect(result).toBe(returnValue); +}); + +test(`#deleteFromNamespaces`, async () => { + const returnValue = Symbol(); + const mockRepository = { + deleteFromNamespaces: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const type = Symbol(); + const id = Symbol(); + const namespaces = Symbol(); + const options = Symbol(); + const result = await client.deleteFromNamespaces(type, id, namespaces, options); + + expect(mockRepository.deleteFromNamespaces).toHaveBeenCalledWith(type, id, namespaces, options); + expect(result).toBe(returnValue); +}); diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 70d69374ba8fe4..8780f07cc3091b 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -107,6 +107,26 @@ export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions { refresh?: MutatingOperationRefreshSetting; } +/** + * + * @public + */ +export interface SavedObjectsAddToNamespacesOptions extends SavedObjectsBaseOptions { + /** An opaque version number which changes on each successful write operation. Can be used for implementing optimistic concurrency control. */ + version?: string; + /** The Elasticsearch Refresh setting for this operation */ + refresh?: MutatingOperationRefreshSetting; +} + +/** + * + * @public + */ +export interface SavedObjectsDeleteFromNamespacesOptions extends SavedObjectsBaseOptions { + /** The Elasticsearch Refresh setting for this operation */ + refresh?: MutatingOperationRefreshSetting; +} + /** * * @public @@ -270,6 +290,40 @@ export class SavedObjectsClient { return await this._repository.update(type, id, attributes, options); } + /** + * Adds namespaces to a SavedObject + * + * @param type + * @param id + * @param namespaces + * @param options + */ + async addToNamespaces( + type: string, + id: string, + namespaces: string[], + options: SavedObjectsAddToNamespacesOptions = {} + ): Promise<{}> { + return await this._repository.addToNamespaces(type, id, namespaces, options); + } + + /** + * Removes namespaces from a SavedObject + * + * @param type + * @param id + * @param namespaces + * @param options + */ + async deleteFromNamespaces( + type: string, + id: string, + namespaces: string[], + options: SavedObjectsDeleteFromNamespacesOptions = {} + ): Promise<{}> { + return await this._repository.deleteFromNamespaces(type, id, namespaces, options); + } + /** * Bulk Updates multiple SavedObject at once * diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index f14e9d9efb5e3d..9efc82603b1796 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -172,6 +172,19 @@ export type MutatingOperationRefreshSetting = boolean | 'wait_for'; */ export type SavedObjectsClientContract = Pick; +/** + * The namespace type dictates how a saved object can be interacted in relation to namespaces. Each type is mutually exclusive: + * * single (default): this type of saved object is namespace-isolated, e.g., it exists in only one namespace. + * * multiple: this type of saved object is shareable, e.g., it can exist in one or more namespaces. + * * agnostic: this type of saved object is global. + * + * Note: do not write logic that uses this value directly; instead, use the appropriate accessors in the + * {@link SavedObjectTypeRegistry | type registry}. + * + * @public + */ +export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; + /** * @remarks This is only internal for now, and will only be public when we expose the registerType API * @@ -190,9 +203,14 @@ export interface SavedObjectsType { */ hidden: boolean; /** - * Is the type global (true), or namespaced (false). + * Is the type global (true), or not (false). + * @deprecated Use `namespaceType` instead. + */ + namespaceAgnostic?: boolean; + /** + * The {@link SavedObjectsNamespaceType | namespace type} for the type. */ - namespaceAgnostic: boolean; + namespaceType?: SavedObjectsNamespaceType; /** * If defined, the type instances will be stored in the given index instead of the default one. */ @@ -329,6 +347,8 @@ export type SavedObjectLegacyMigrationFn = ( */ interface SavedObjectsLegacyTypeSchema { isNamespaceAgnostic?: boolean; + /** Cannot be used in conjunction with `isNamespaceAgnostic` */ + multiNamespace?: boolean; hidden?: boolean; indexPattern?: ((config: LegacyConfig) => string) | string; convertToAliasScript?: string; diff --git a/src/core/server/saved_objects/utils.test.ts b/src/core/server/saved_objects/utils.test.ts index 0719fe7138e8a9..64bdf1771deccc 100644 --- a/src/core/server/saved_objects/utils.test.ts +++ b/src/core/server/saved_objects/utils.test.ts @@ -84,6 +84,16 @@ describe('convertLegacyTypes', () => { }, { pluginId: 'pluginB', + properties: { + typeB: { + properties: { + fieldB: { type: 'text' }, + }, + }, + }, + }, + { + pluginId: 'pluginC', properties: { typeC: { properties: { @@ -92,6 +102,16 @@ describe('convertLegacyTypes', () => { }, }, }, + { + pluginId: 'pluginD', + properties: { + typeD: { + properties: { + fieldD: { type: 'text' }, + }, + }, + }, + }, ], savedObjectMigrations: {}, savedObjectSchemas: { @@ -100,6 +120,18 @@ describe('convertLegacyTypes', () => { hidden: true, isNamespaceAgnostic: true, }, + typeB: { + indexPattern: 'barBaz', + hidden: false, + multiNamespace: true, + }, + typeD: { + indexPattern: 'bazQux', + hidden: false, + // if both isNamespaceAgnostic and multiNamespace are true, the resulting namespaceType is 'agnostic' + isNamespaceAgnostic: true, + multiNamespace: true, + }, }, savedObjectValidations: {}, savedObjectsManagement: {}, @@ -372,29 +404,56 @@ describe('convertTypesToLegacySchema', () => { { name: 'typeA', hidden: false, - namespaceAgnostic: true, + namespaceType: 'agnostic', mappings: { properties: {} }, convertToAliasScript: 'some script', }, { name: 'typeB', hidden: true, - namespaceAgnostic: false, + namespaceType: 'single', indexPattern: 'myIndex', mappings: { properties: {} }, }, + { + name: 'typeC', + hidden: false, + namespaceType: 'multiple', + mappings: { properties: {} }, + }, + // deprecated test case + { + name: 'typeD', + hidden: false, + namespaceAgnostic: true, + namespaceType: 'multiple', // if namespaceAgnostic and namespaceType are both set, namespaceAgnostic takes precedence + mappings: { properties: {} }, + }, ]; expect(convertTypesToLegacySchema(types)).toEqual({ typeA: { hidden: false, isNamespaceAgnostic: true, + multiNamespace: false, convertToAliasScript: 'some script', }, typeB: { hidden: true, isNamespaceAgnostic: false, + multiNamespace: false, indexPattern: 'myIndex', }, + typeC: { + hidden: false, + isNamespaceAgnostic: false, + multiNamespace: true, + }, + // deprecated test case + typeD: { + hidden: false, + isNamespaceAgnostic: true, + multiNamespace: false, + }, }); }); }); diff --git a/src/core/server/saved_objects/utils.ts b/src/core/server/saved_objects/utils.ts index ea90efd8b9fbd0..53489638126293 100644 --- a/src/core/server/saved_objects/utils.ts +++ b/src/core/server/saved_objects/utils.ts @@ -20,6 +20,7 @@ import { LegacyConfig } from '../legacy'; import { SavedObjectMigrationMap } from './migrations'; import { + SavedObjectsNamespaceType, SavedObjectsType, SavedObjectsLegacyUiExports, SavedObjectLegacyMigrationMap, @@ -48,10 +49,15 @@ export const convertLegacyTypes = ( const schema = savedObjectSchemas[type]; const migrations = savedObjectMigrations[type]; const management = savedObjectsManagement[type]; + const namespaceType = (schema?.isNamespaceAgnostic + ? 'agnostic' + : schema?.multiNamespace + ? 'multiple' + : 'single') as SavedObjectsNamespaceType; return { name: type, hidden: schema?.hidden ?? false, - namespaceAgnostic: schema?.isNamespaceAgnostic ?? false, + namespaceType, mappings, indexPattern: typeof schema?.indexPattern === 'function' @@ -76,7 +82,8 @@ export const convertTypesToLegacySchema = ( return { ...schema, [type.name]: { - isNamespaceAgnostic: type.namespaceAgnostic, + isNamespaceAgnostic: type.namespaceAgnostic || type.namespaceType === 'agnostic', + multiNamespace: !type.namespaceAgnostic && type.namespaceType === 'multiple', hidden: type.hidden, indexPattern: type.indexPattern, convertToAliasScript: type.convertToAliasScript, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index f3e3b7736d8d30..a35bca7375286d 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1003,7 +1003,7 @@ export type IsAuthenticated = (request: KibanaRequest | LegacyRequest) => boolea export type ISavedObjectsRepository = Pick; // @public -export type ISavedObjectTypeRegistry = Pick; +export type ISavedObjectTypeRegistry = Omit; // @public export type IScopedClusterClient = Pick; @@ -1643,6 +1643,7 @@ export interface SavedObject { }; id: string; migrationVersion?: SavedObjectsMigrationVersion; + namespaces?: string[]; references: SavedObjectReference[]; type: string; updated_at?: string; @@ -1687,6 +1688,12 @@ export interface SavedObjectReference { type: string; } +// @public (undocumented) +export interface SavedObjectsAddToNamespacesOptions extends SavedObjectsBaseOptions { + refresh?: MutatingOperationRefreshSetting; + version?: string; +} + // Warning: (ae-forgotten-export) The symbol "SavedObjectDoc" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "Referencable" needs to be exported by the entry point index.d.ts // @@ -1754,11 +1761,13 @@ export interface SavedObjectsBulkUpdateResponse { export class SavedObjectsClient { // @internal constructor(repository: ISavedObjectsRepository); + addToNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsAddToNamespacesOptions): Promise<{}>; bulkCreate(objects: Array>, options?: SavedObjectsCreateOptions): Promise>; bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; + deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise<{}>; // (undocumented) static errors: typeof SavedObjectsErrorHelpers; // (undocumented) @@ -1839,6 +1848,11 @@ export interface SavedObjectsDeleteByNamespaceOptions extends SavedObjectsBaseOp refresh?: MutatingOperationRefreshSetting; } +// @public (undocumented) +export interface SavedObjectsDeleteFromNamespacesOptions extends SavedObjectsBaseOptions { + refresh?: MutatingOperationRefreshSetting; +} + // @public (undocumented) export interface SavedObjectsDeleteOptions extends SavedObjectsBaseOptions { refresh?: MutatingOperationRefreshSetting; @@ -1849,6 +1863,8 @@ export class SavedObjectsErrorHelpers { // (undocumented) static createBadRequestError(reason?: string): DecoratedError; // (undocumented) + static createConflictError(type: string, id: string): DecoratedError; + // (undocumented) static createEsAutoCreateIndexError(): DecoratedError; // (undocumented) static createGenericNotFoundError(type?: string | null, id?: string | null): DecoratedError; @@ -1861,6 +1877,8 @@ export class SavedObjectsErrorHelpers { // (undocumented) static decorateConflictError(error: Error, reason?: string): DecoratedError; // (undocumented) + static decorateEsCannotExecuteScriptError(error: Error, reason?: string): DecoratedError; + // (undocumented) static decorateEsUnavailableError(error: Error, reason?: string): DecoratedError; // (undocumented) static decorateForbiddenError(error: Error, reason?: string): DecoratedError; @@ -1877,6 +1895,8 @@ export class SavedObjectsErrorHelpers { // (undocumented) static isEsAutoCreateIndexError(error: Error | DecoratedError): boolean; // (undocumented) + static isEsCannotExecuteScriptError(error: Error | DecoratedError): boolean; + // (undocumented) static isEsUnavailableError(error: Error | DecoratedError): boolean; // (undocumented) static isForbiddenError(error: Error | DecoratedError): boolean; @@ -2106,6 +2126,9 @@ export interface SavedObjectsMigrationVersion { [pluginName: string]: string; } +// @public +export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'agnostic'; + // @public export interface SavedObjectsRawDoc { // (undocumented) @@ -2124,6 +2147,7 @@ export interface SavedObjectsRawDoc { // @public (undocumented) export class SavedObjectsRepository { + addToNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsAddToNamespacesOptions): Promise<{}>; bulkCreate(objects: Array>, options?: SavedObjectsCreateOptions): Promise>; bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; @@ -2134,6 +2158,7 @@ export class SavedObjectsRepository { static createRepository(migrator: KibanaMigrator, typeRegistry: SavedObjectTypeRegistry, indexName: string, callCluster: APICaller, extraTypes?: string[], injectedConstructor?: any): ISavedObjectsRepository; delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; deleteByNamespace(namespace: string, options?: SavedObjectsDeleteByNamespaceOptions): Promise; + deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise<{}>; // (undocumented) find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, }: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; @@ -2175,7 +2200,11 @@ export class SavedObjectsSchema { // (undocumented) isHiddenType(type: string): boolean; // (undocumented) + isMultiNamespace(type: string): boolean; + // (undocumented) isNamespaceAgnostic(type: string): boolean; + // (undocumented) + isSingleNamespace(type: string): boolean; } // @public @@ -2224,7 +2253,9 @@ export interface SavedObjectsType { mappings: SavedObjectsTypeMappingDefinition; migrations?: SavedObjectMigrationMap; name: string; - namespaceAgnostic: boolean; + // @deprecated + namespaceAgnostic?: boolean; + namespaceType?: SavedObjectsNamespaceType; } // @public @@ -2269,7 +2300,9 @@ export class SavedObjectTypeRegistry { getType(type: string): SavedObjectsType | undefined; isHidden(type: string): boolean; isImportableAndExportable(type: string): boolean; + isMultiNamespace(type: string): boolean; isNamespaceAgnostic(type: string): boolean; + isSingleNamespace(type: string): boolean; registerType(type: SavedObjectsType): void; } diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index d3faab6c557cd6..04aaacc3cf31ab 100644 --- a/src/core/types/saved_objects.ts +++ b/src/core/types/saved_objects.ts @@ -96,4 +96,6 @@ export interface SavedObject { references: SavedObjectReference[]; /** {@inheritdoc SavedObjectsMigrationVersion} */ migrationVersion?: SavedObjectsMigrationVersion; + /** Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. */ + namespaces?: string[]; } diff --git a/test/api_integration/apis/saved_objects/bulk_create.js b/test/api_integration/apis/saved_objects/bulk_create.js index afa5153336ff7b..2d77fdf266793a 100644 --- a/test/api_integration/apis/saved_objects/bulk_create.js +++ b/test/api_integration/apis/saved_objects/bulk_create.js @@ -58,7 +58,9 @@ export default function({ getService }) { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', error: { - message: 'version conflict, document already exists', + error: 'Conflict', + message: + 'Saved object [visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab] conflict', statusCode: 409, }, }, diff --git a/test/api_integration/apis/saved_objects/bulk_get.js b/test/api_integration/apis/saved_objects/bulk_get.js index 984ac781d3e462..67e511f2bf548f 100644 --- a/test/api_integration/apis/saved_objects/bulk_get.js +++ b/test/api_integration/apis/saved_objects/bulk_get.js @@ -80,8 +80,9 @@ export default function({ getService }) { id: 'does not exist', type: 'dashboard', error: { + error: 'Not Found', + message: 'Saved object [dashboard/does not exist] not found', statusCode: 404, - message: 'Not found', }, }, { @@ -123,24 +124,28 @@ export default function({ getService }) { id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', type: 'visualization', error: { + error: 'Not Found', + message: + 'Saved object [visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab] not found', statusCode: 404, - message: 'Not found', }, }, { id: 'does not exist', type: 'dashboard', error: { + error: 'Not Found', + message: 'Saved object [dashboard/does not exist] not found', statusCode: 404, - message: 'Not found', }, }, { id: '7.0.0-alpha1', type: 'config', error: { + error: 'Not Found', + message: 'Saved object [config/7.0.0-alpha1] not found', statusCode: 404, - message: 'Not found', }, }, ], diff --git a/test/api_integration/apis/saved_objects/export.js b/test/api_integration/apis/saved_objects/export.js index fc9ab8140869c9..9558e82880deb9 100644 --- a/test/api_integration/apis/saved_objects/export.js +++ b/test/api_integration/apis/saved_objects/export.js @@ -170,8 +170,9 @@ export default function({ getService }) { id: '1', type: 'dashboard', error: { + error: 'Not Found', + message: 'Saved object [dashboard/1] not found', statusCode: 404, - message: 'Not found', }, }, ], diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index f691e62b9352a6..2caf3a7055df92 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -10,14 +10,16 @@ import { SavedObjectsClientContract } from 'src/core/server'; import { EncryptedSavedObjectsService } from '../crypto'; import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; -import { savedObjectsClientMock } from 'src/core/server/mocks'; +import { savedObjectsClientMock, savedObjectsTypeRegistryMock } from 'src/core/server/mocks'; import { encryptedSavedObjectsServiceMock } from '../crypto/index.mock'; let wrapper: EncryptedSavedObjectsClientWrapper; let mockBaseClient: jest.Mocked; +let mockBaseTypeRegistry: ReturnType; let encryptedSavedObjectsServiceMockInstance: jest.Mocked; beforeEach(() => { mockBaseClient = savedObjectsClientMock.create(); + mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create(); encryptedSavedObjectsServiceMockInstance = encryptedSavedObjectsServiceMock.create([ { type: 'known-type', @@ -28,6 +30,7 @@ beforeEach(() => { wrapper = new EncryptedSavedObjectsClientWrapper({ service: encryptedSavedObjectsServiceMockInstance, baseClient: mockBaseClient, + baseTypeRegistry: mockBaseTypeRegistry, } as any); }); @@ -91,35 +94,50 @@ describe('#create', () => { ); }); - it('uses `namespace` to encrypt attributes if it is specified', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const options = { overwrite: true, namespace: 'some-namespace' }; - const mockedResponse = { - id: 'uuid-v4-id', - type: 'known-type', - attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - references: [], - }; + describe('namespace', () => { + const doTest = async (namespace: string, expectNamespaceInDescriptor: boolean) => { + const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; + const options = { overwrite: true, namespace }; + const mockedResponse = { + id: 'uuid-v4-id', + type: 'known-type', + attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, + references: [], + }; - mockBaseClient.create.mockResolvedValue(mockedResponse); + mockBaseClient.create.mockResolvedValue(mockedResponse); - expect(await wrapper.create('known-type', attributes, options)).toEqual({ - ...mockedResponse, - attributes: { attrOne: 'one', attrThree: 'three' }, - }); + expect(await wrapper.create('known-type', attributes, options)).toEqual({ + ...mockedResponse, + attributes: { attrOne: 'one', attrThree: 'three' }, + }); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'uuid-v4-id', namespace: 'some-namespace' }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } - ); + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( + { + type: 'known-type', + id: 'uuid-v4-id', + namespace: expectNamespaceInDescriptor ? namespace : undefined, + }, + { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } + ); - expect(mockBaseClient.create).toHaveBeenCalledTimes(1); - expect(mockBaseClient.create).toHaveBeenCalledWith( - 'known-type', - { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - { id: 'uuid-v4-id', overwrite: true, namespace: 'some-namespace' } - ); + expect(mockBaseClient.create).toHaveBeenCalledTimes(1); + expect(mockBaseClient.create).toHaveBeenCalledWith( + 'known-type', + { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, + { id: 'uuid-v4-id', overwrite: true, namespace } + ); + }; + + 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 `namespace` to encrypt attributes if it is specified when type is not single-namespace', async () => { + mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false); + await doTest('some-namespace', false); + }); }); it('fails if base client fails', async () => { @@ -190,14 +208,13 @@ describe('#bulkCreate', () => { it('fails if ID is specified for registered type', async () => { const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const options = { namespace: 'some-namespace' }; const bulkCreateParams = [ { id: 'some-id', type: 'known-type', attributes }, { type: 'unknown-type', attributes }, ]; - await expect(wrapper.bulkCreate(bulkCreateParams, options)).rejects.toThrowError( + await expect(wrapper.bulkCreate(bulkCreateParams)).rejects.toThrowError( 'Predefined IDs are not allowed for saved objects with encrypted attributes.' ); @@ -257,39 +274,57 @@ describe('#bulkCreate', () => { ); }); - it('uses `namespace` to encrypt attributes if it is specified', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const options = { namespace: 'some-namespace' }; - const mockedResponse = { - saved_objects: [{ id: 'uuid-v4-id', type: 'known-type', attributes, references: [] }], - }; + describe('namespace', () => { + const doTest = async (namespace: string, expectNamespaceInDescriptor: boolean) => { + const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; + const options = { namespace }; + const mockedResponse = { + saved_objects: [{ id: 'uuid-v4-id', type: 'known-type', attributes, references: [] }], + }; + + mockBaseClient.bulkCreate.mockResolvedValue(mockedResponse); + + const bulkCreateParams = [{ type: 'known-type', attributes }]; + await expect(wrapper.bulkCreate(bulkCreateParams, options)).resolves.toEqual({ + saved_objects: [ + { + ...mockedResponse.saved_objects[0], + attributes: { attrOne: 'one', attrThree: 'three' }, + }, + ], + }); - mockBaseClient.bulkCreate.mockResolvedValue(mockedResponse); + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( + { + type: 'known-type', + id: 'uuid-v4-id', + namespace: expectNamespaceInDescriptor ? namespace : undefined, + }, + { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } + ); + + expect(mockBaseClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith( + [ + { + ...bulkCreateParams[0], + id: 'uuid-v4-id', + attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, + }, + ], + options + ); + }; - const bulkCreateParams = [{ type: 'known-type', attributes }]; - await expect(wrapper.bulkCreate(bulkCreateParams, options)).resolves.toEqual({ - saved_objects: [ - { ...mockedResponse.saved_objects[0], attributes: { attrOne: 'one', attrThree: 'three' } }, - ], + it('uses `namespace` to encrypt attributes if it is specified when type is single-namespace', async () => { + await doTest('some-namespace', true); }); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'uuid-v4-id', namespace: 'some-namespace' }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } - ); - - expect(mockBaseClient.bulkCreate).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith( - [ - { - ...bulkCreateParams[0], - id: 'uuid-v4-id', - attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - }, - ], - options - ); + 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); + }); }); it('fails if base client fails', async () => { @@ -432,63 +467,79 @@ describe('#bulkUpdate', () => { ); }); - 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: [ + describe('namespace', () => { + const doTest = async (namespace: string, expectNamespaceInDescriptor: boolean) => { + const docs = [ { id: 'some-id', type: 'known-type', attributes: { attrOne: 'one', + attrSecret: 'secret', attrThree: 'three', }, version: 'some-version', - references: undefined, }, - ], - }); - - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'some-id', namespace: 'some-namespace' }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } - ); + ]; + const options = { namespace }; + + mockBaseClient.bulkUpdate.mockResolvedValue({ + saved_objects: docs.map(doc => ({ ...doc, references: undefined })), + }); + + await expect(wrapper.bulkUpdate(docs, options)).resolves.toEqual({ + saved_objects: [ + { + id: 'some-id', + type: 'known-type', + attributes: { + attrOne: 'one', + attrThree: 'three', + }, + version: 'some-version', + references: undefined, + }, + ], + }); - expect(mockBaseClient.bulkUpdate).toHaveBeenCalledTimes(1); - expect(mockBaseClient.bulkUpdate).toHaveBeenCalledWith( - [ + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( { - id: 'some-id', type: 'known-type', - attributes: { - attrOne: 'one', - attrSecret: '*secret*', - attrThree: 'three', + id: 'some-id', + namespace: expectNamespaceInDescriptor ? namespace : undefined, + }, + { 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, }, - version: 'some-version', + ], + options + ); + }; - references: undefined, - }, - ], - { namespace: 'some-namespace' } - ); + 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 `namespace` to encrypt attributes if it is specified when type is not single-namespace', async () => { + mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false); + await doTest('some-namespace', false); + }); }); it('fails if base client fails', async () => { @@ -871,31 +922,46 @@ describe('#update', () => { ); }); - it('uses `namespace` to encrypt attributes if it is specified', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const options = { version: 'some-version', namespace: 'some-namespace' }; - const mockedResponse = { id: 'some-id', type: 'known-type', attributes, references: [] }; + describe('namespace', () => { + const doTest = async (namespace: string, expectNamespaceInDescriptor: boolean) => { + const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; + const options = { version: 'some-version', namespace }; + const mockedResponse = { id: 'some-id', type: 'known-type', attributes, references: [] }; - mockBaseClient.update.mockResolvedValue(mockedResponse); + mockBaseClient.update.mockResolvedValue(mockedResponse); - await expect(wrapper.update('known-type', 'some-id', attributes, options)).resolves.toEqual({ - ...mockedResponse, - attributes: { attrOne: 'one', attrThree: 'three' }, - }); + await expect(wrapper.update('known-type', 'some-id', attributes, options)).resolves.toEqual({ + ...mockedResponse, + attributes: { attrOne: 'one', attrThree: 'three' }, + }); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); - expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'some-id', namespace: 'some-namespace' }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } - ); + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); + expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( + { + type: 'known-type', + id: 'some-id', + namespace: expectNamespaceInDescriptor ? namespace : undefined, + }, + { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } + ); + + expect(mockBaseClient.update).toHaveBeenCalledTimes(1); + expect(mockBaseClient.update).toHaveBeenCalledWith( + 'known-type', + 'some-id', + { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, + options + ); + }; - expect(mockBaseClient.update).toHaveBeenCalledTimes(1); - expect(mockBaseClient.update).toHaveBeenCalledWith( - 'known-type', - 'some-id', - { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - options - ); + 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 `namespace` to encrypt attributes if it is specified when type is not single-namespace', async () => { + mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false); + await doTest('some-namespace', false); + }); }); it('fails if base client fails', async () => { diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index b4f1de52c9ce3b..e8197536d29d93 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -19,11 +19,15 @@ import { SavedObjectsFindResponse, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, + SavedObjectsAddToNamespacesOptions, + SavedObjectsDeleteFromNamespacesOptions, + ISavedObjectTypeRegistry, } from 'src/core/server'; import { EncryptedSavedObjectsService } from '../crypto'; interface EncryptedSavedObjectsClientOptions { baseClient: SavedObjectsClientContract; + baseTypeRegistry: ISavedObjectTypeRegistry; service: Readonly; } @@ -41,6 +45,10 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon public readonly errors = options.baseClient.errors ) {} + // only include namespace in AAD descriptor if the specified type is single-namespace + private getDescriptorNamespace = (type: string, namespace?: string) => + this.options.baseTypeRegistry.isSingleNamespace(type) ? namespace : undefined; + public async create( type: string, attributes: T = {} as T, @@ -60,11 +68,12 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon } const id = generateID(); + const namespace = this.getDescriptorNamespace(type, options.namespace); return this.stripEncryptedAttributesFromResponse( await this.options.baseClient.create( type, await this.options.service.encryptAttributes( - { type, id, namespace: options.namespace }, + { type, id, namespace }, attributes as Record ), { ...options, id } @@ -95,11 +104,12 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon } const id = generateID(); + const namespace = this.getDescriptorNamespace(object.type, options?.namespace); return { ...object, id, attributes: await this.options.service.encryptAttributes( - { type: object.type, id, namespace: options && options.namespace }, + { type: object.type, id, namespace }, object.attributes as Record ), } as SavedObjectsBulkCreateObject; @@ -124,10 +134,11 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon if (!this.options.service.isRegistered(type)) { return object; } + const namespace = this.getDescriptorNamespace(type, options?.namespace); return { ...object, attributes: await this.options.service.encryptAttributes( - { type, id, namespace: options && options.namespace }, + { type, id, namespace }, attributes ), }; @@ -173,20 +184,35 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon if (!this.options.service.isRegistered(type)) { return await this.options.baseClient.update(type, id, attributes, options); } - + const namespace = this.getDescriptorNamespace(type, options?.namespace); return this.stripEncryptedAttributesFromResponse( await this.options.baseClient.update( type, id, - await this.options.service.encryptAttributes( - { type, id, namespace: options && options.namespace }, - attributes - ), + await this.options.service.encryptAttributes({ type, id, namespace }, attributes), options ) ); } + public async addToNamespaces( + type: string, + id: string, + namespaces: string[], + options?: SavedObjectsAddToNamespacesOptions + ) { + return await this.options.baseClient.addToNamespaces(type, id, namespaces, options); + } + + public async deleteFromNamespaces( + type: string, + id: string, + namespaces: string[], + options?: SavedObjectsDeleteFromNamespacesOptions + ) { + return await this.options.baseClient.deleteFromNamespaces(type, id, namespaces, options); + } + /** * Strips encrypted attributes from any non-bulk Saved Objects API response. If type isn't * registered, response is returned as is. diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts index c76477cd8da439..10599ae3a17986 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts @@ -40,7 +40,8 @@ export function setupSavedObjects({ savedObjects.addClientWrapper( Number.MAX_SAFE_INTEGER, 'encryptedSavedObjects', - ({ client: baseClient }) => new EncryptedSavedObjectsClientWrapper({ baseClient, service }) + ({ client: baseClient, typeRegistry: baseTypeRegistry }) => + new EncryptedSavedObjectsClientWrapper({ baseClient, baseTypeRegistry, service }) ); const internalRepositoryPromise = getStartServices().then(([core]) => diff --git a/x-pack/plugins/security/server/audit/audit_logger.test.ts b/x-pack/plugins/security/server/audit/audit_logger.test.ts index 01cde02b7dfdd2..f7ee210a21a741 100644 --- a/x-pack/plugins/security/server/audit/audit_logger.test.ts +++ b/x-pack/plugins/security/server/audit/audit_logger.test.ts @@ -18,25 +18,46 @@ describe(`#savedObjectsAuthorizationFailure`, () => { const username = 'foo-user'; const action = 'foo-action'; const types = ['foo-type-1', 'foo-type-2']; - const missing = [`saved_object:${types[0]}/foo-action`, `saved_object:${types[1]}/foo-action`]; + const spaceIds = ['foo-space', 'bar-space']; + const missing = [ + { + spaceId: 'foo-space', + privilege: `saved_object:${types[0]}/${action}`, + }, + { + spaceId: 'foo-space', + privilege: `saved_object:${types[1]}/${action}`, + }, + ]; const args = { foo: 'bar', baz: 'quz', }; - securityAuditLogger.savedObjectsAuthorizationFailure(username, action, types, missing, args); + securityAuditLogger.savedObjectsAuthorizationFailure( + username, + action, + types, + spaceIds, + missing, + args + ); expect(auditLogger.log).toHaveBeenCalledWith( 'saved_objects_authorization_failure', - expect.stringContaining(`${username} unauthorized to ${action}`), + expect.any(String), { username, action, types, + spaceIds, missing, args, } ); + expect(auditLogger.log.mock.calls[0][1]).toMatchInlineSnapshot( + `"foo-user unauthorized to [foo-action] [foo-type-1,foo-type-2] in [foo-space,bar-space]: missing [(foo-space)saved_object:foo-type-1/foo-action,(foo-space)saved_object:foo-type-2/foo-action]"` + ); }); }); @@ -47,22 +68,27 @@ describe(`#savedObjectsAuthorizationSuccess`, () => { const username = 'foo-user'; const action = 'foo-action'; const types = ['foo-type-1', 'foo-type-2']; + const spaceIds = ['foo-space', 'bar-space']; const args = { foo: 'bar', baz: 'quz', }; - securityAuditLogger.savedObjectsAuthorizationSuccess(username, action, types, args); + securityAuditLogger.savedObjectsAuthorizationSuccess(username, action, types, spaceIds, args); expect(auditLogger.log).toHaveBeenCalledWith( 'saved_objects_authorization_success', - expect.stringContaining(`${username} authorized to ${action}`), + expect.any(String), { username, action, types, + spaceIds, args, } ); + expect(auditLogger.log.mock.calls[0][1]).toMatchInlineSnapshot( + `"foo-user authorized to [foo-action] [foo-type-1,foo-type-2] in [foo-space,bar-space]"` + ); }); }); diff --git a/x-pack/plugins/security/server/audit/audit_logger.ts b/x-pack/plugins/security/server/audit/audit_logger.ts index df8df35f97b49c..40b525b5d21888 100644 --- a/x-pack/plugins/security/server/audit/audit_logger.ts +++ b/x-pack/plugins/security/server/audit/audit_logger.ts @@ -13,16 +13,23 @@ export class SecurityAuditLogger { username: string, action: string, types: string[], - missing: string[], + spaceIds: string[], + missing: Array<{ spaceId?: string; privilege: string }>, args?: Record ) { + const typesString = types.join(','); + const spacesString = spaceIds.length ? ` in [${spaceIds.join(',')}]` : ''; + const missingString = missing + .map(({ spaceId, privilege }) => `${spaceId ? `(${spaceId})` : ''}${privilege}`) + .join(','); this.getAuditLogger().log( 'saved_objects_authorization_failure', - `${username} unauthorized to ${action} ${types.join(',')}, missing ${missing.join(',')}`, + `${username} unauthorized to [${action}] [${typesString}]${spacesString}: missing [${missingString}]`, { username, action, types, + spaceIds, missing, args, } @@ -33,15 +40,19 @@ export class SecurityAuditLogger { username: string, action: string, types: string[], + spaceIds: string[], args?: Record ) { + const typesString = types.join(','); + const spacesString = spaceIds.length ? ` in [${spaceIds.join(',')}]` : ''; this.getAuditLogger().log( 'saved_objects_authorization_success', - `${username} authorized to ${action} ${types.join(',')}`, + `${username} authorized to [${action}] [${typesString}]${spacesString}`, { username, action, types, + spaceIds, args, } ); diff --git a/x-pack/plugins/security/server/authorization/__snapshots__/check_privileges.test.ts.snap b/x-pack/plugins/security/server/authorization/__snapshots__/check_privileges.test.ts.snap deleted file mode 100644 index 1212c2cd6a5cb2..00000000000000 --- a/x-pack/plugins/security/server/authorization/__snapshots__/check_privileges.test.ts.snap +++ /dev/null @@ -1,27 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`#atSpace throws error when checking for login and user has login but doesn't have version 1`] = `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]`; - -exports[`#atSpace with a malformed Elasticsearch response throws a validation error when an extra privilege is present in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_1" fails because ["saved_object:bar-type/get" is not allowed]]]]`; - -exports[`#atSpace with a malformed Elasticsearch response throws a validation error when privileges are missing in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_1" fails because [child "saved_object:foo-type/get" fails because ["saved_object:foo-type/get" is required]]]]]`; - -exports[`#atSpaces throws error when Elasticsearch returns malformed response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_1" fails because [child "mock-action:version" fails because ["mock-action:version" is required]]]]]`; - -exports[`#atSpaces throws error when checking for login and user has login but doesn't have version 1`] = `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]`; - -exports[`#atSpaces with a malformed Elasticsearch response throws a validation error when an a space is missing in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_2" fails because ["space:space_2" is required]]]]`; - -exports[`#atSpaces with a malformed Elasticsearch response throws a validation error when an extra privilege is present in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_2" fails because ["space:space_2" is required]]]]`; - -exports[`#atSpaces with a malformed Elasticsearch response throws a validation error when an extra space is present in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because ["space:space_3" is not allowed]]]`; - -exports[`#atSpaces with a malformed Elasticsearch response throws a validation error when privileges are missing in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_2" fails because ["space:space_2" is required]]]]`; - -exports[`#globally throws error when Elasticsearch returns malformed response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because [child "mock-action:version" fails because ["mock-action:version" is required]]]]]`; - -exports[`#globally throws error when checking for login and user has login but doesn't have version 1`] = `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]`; - -exports[`#globally with a malformed Elasticsearch response throws a validation error when an extra privilege is present in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because ["saved_object:bar-type/get" is not allowed]]]]`; - -exports[`#globally with a malformed Elasticsearch response throws a validation error when privileges are missing in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because [child "saved_object:foo-type/get" fails because ["saved_object:foo-type/get" is required]]]]]`; diff --git a/x-pack/plugins/security/server/authorization/check_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_privileges.test.ts index 8c1241937892e1..a64c5d509ca11c 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.test.ts @@ -31,123 +31,121 @@ const createMockClusterClient = (response: any) => { }; describe('#atSpace', () => { - const checkPrivilegesAtSpaceTest = ( - description: string, - options: { - spaceId: string; - privilegeOrPrivileges: string | string[]; - esHasPrivilegesResponse: HasPrivilegesResponse; - expectedResult?: any; - expectErrorThrown?: any; + const checkPrivilegesAtSpaceTest = async (options: { + spaceId: string; + privilegeOrPrivileges: string | string[]; + esHasPrivilegesResponse: HasPrivilegesResponse; + }) => { + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( + options.esHasPrivilegesResponse + ); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( + mockActions, + mockClusterClient, + application + ); + const request = httpServerMock.createKibanaRequest(); + const checkPrivileges = checkPrivilegesWithRequest(request); + + let actualResult; + let errorThrown = null; + try { + actualResult = await checkPrivileges.atSpace(options.spaceId, options.privilegeOrPrivileges); + } catch (err) { + errorThrown = err; } - ) => { - test(description, async () => { - const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( - options.esHasPrivilegesResponse - ); - const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( - mockActions, - mockClusterClient, - application - ); - const request = httpServerMock.createKibanaRequest(); - const checkPrivileges = checkPrivilegesWithRequest(request); - - let actualResult; - let errorThrown = null; - try { - actualResult = await checkPrivileges.atSpace( - options.spaceId, - options.privilegeOrPrivileges - ); - } catch (err) { - errorThrown = err; - } - expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( - 'shield.hasPrivileges', - { - body: { - applications: [ - { - application, - resources: [`space:${options.spaceId}`], - privileges: uniq([ - mockActions.version, - mockActions.login, - ...(Array.isArray(options.privilegeOrPrivileges) - ? options.privilegeOrPrivileges - : [options.privilegeOrPrivileges]), - ]), - }, - ], + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.hasPrivileges', { + body: { + applications: [ + { + application, + resources: [`space:${options.spaceId}`], + privileges: uniq([ + mockActions.version, + mockActions.login, + ...(Array.isArray(options.privilegeOrPrivileges) + ? options.privilegeOrPrivileges + : [options.privilegeOrPrivileges]), + ]), }, - } - ); - - if (options.expectedResult) { - expect(errorThrown).toBeNull(); - expect(actualResult).toEqual(options.expectedResult); - } - - if (options.expectErrorThrown) { - expect(errorThrown).toMatchSnapshot(); - } + ], + }, }); + + if (errorThrown) { + return errorThrown; + } + return actualResult; }; - checkPrivilegesAtSpaceTest('successful when checking for login and user has login', { - spaceId: 'space_1', - privilegeOrPrivileges: mockActions.login, - esHasPrivilegesResponse: { - has_all_requested: true, - username: 'foo-username', - application: { - [application]: { - 'space:space_1': { - [mockActions.login]: true, - [mockActions.version]: true, + test('successful when checking for login and user has login', async () => { + const result = await checkPrivilegesAtSpaceTest({ + spaceId: 'space_1', + privilegeOrPrivileges: mockActions.login, + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + }, }, }, }, - }, - expectedResult: { - hasAllRequested: true, - username: 'foo-username', - privileges: { - [mockActions.login]: true, - }, - }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Array [ + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": "space_1", + }, + ], + "username": "foo-username", + } + `); }); - checkPrivilegesAtSpaceTest(`failure when checking for login and user doesn't have login`, { - spaceId: 'space_1', - privilegeOrPrivileges: mockActions.login, - esHasPrivilegesResponse: { - has_all_requested: false, - username: 'foo-username', - application: { - [application]: { - 'space:space_1': { - [mockActions.login]: false, - [mockActions.version]: true, + test(`failure when checking for login and user doesn't have login`, async () => { + const result = await checkPrivilegesAtSpaceTest({ + spaceId: 'space_1', + privilegeOrPrivileges: mockActions.login, + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: false, + [mockActions.version]: true, + }, }, }, }, - }, - expectedResult: { - hasAllRequested: false, - username: 'foo-username', - privileges: { - [mockActions.login]: false, - }, - }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Array [ + Object { + "authorized": false, + "privilege": "mock-action:login", + "resource": "space_1", + }, + ], + "username": "foo-username", + } + `); }); - checkPrivilegesAtSpaceTest( - `throws error when checking for login and user has login but doesn't have version`, - { + test(`throws error when checking for login and user has login but doesn't have version`, async () => { + const result = await checkPrivilegesAtSpaceTest({ spaceId: 'space_1', privilegeOrPrivileges: mockActions.login, esHasPrivilegesResponse: { @@ -162,74 +160,99 @@ describe('#atSpace', () => { }, }, }, - expectErrorThrown: true, - } - ); - - checkPrivilegesAtSpaceTest(`successful when checking for two actions and the user has both`, { - spaceId: 'space_1', - privilegeOrPrivileges: [ - `saved_object:${savedObjectTypes[0]}/get`, - `saved_object:${savedObjectTypes[1]}/get`, - ], - esHasPrivilegesResponse: { - has_all_requested: true, - username: 'foo-username', - application: { - [application]: { - 'space:space_1': { - [mockActions.login]: true, - [mockActions.version]: true, - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: true, + }); + expect(result).toMatchInlineSnapshot( + `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]` + ); + }); + + test(`successful when checking for two actions and the user has both`, async () => { + const result = await checkPrivilegesAtSpaceTest({ + spaceId: 'space_1', + privilegeOrPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: true, + [`saved_object:${savedObjectTypes[1]}/get`]: true, + }, }, }, }, - }, - expectedResult: { - hasAllRequested: true, - username: 'foo-username', - privileges: { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: true, - }, - }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + ], + "username": "foo-username", + } + `); }); - checkPrivilegesAtSpaceTest(`failure when checking for two actions and the user has only one`, { - spaceId: 'space_1', - privilegeOrPrivileges: [ - `saved_object:${savedObjectTypes[0]}/get`, - `saved_object:${savedObjectTypes[1]}/get`, - ], - esHasPrivilegesResponse: { - has_all_requested: false, - username: 'foo-username', - application: { - [application]: { - 'space:space_1': { - [mockActions.login]: true, - [mockActions.version]: true, - [`saved_object:${savedObjectTypes[0]}/get`]: false, - [`saved_object:${savedObjectTypes[1]}/get`]: true, + test(`failure when checking for two actions and the user has only one`, async () => { + const result = await checkPrivilegesAtSpaceTest({ + spaceId: 'space_1', + privilegeOrPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: false, + [`saved_object:${savedObjectTypes[1]}/get`]: true, + }, }, }, }, - }, - expectedResult: { - hasAllRequested: false, - username: 'foo-username', - privileges: { - [`saved_object:${savedObjectTypes[0]}/get`]: false, - [`saved_object:${savedObjectTypes[1]}/get`]: true, - }, - }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Array [ + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", + }, + ], + "username": "foo-username", + } + `); }); describe('with a malformed Elasticsearch response', () => { - checkPrivilegesAtSpaceTest( - `throws a validation error when an extra privilege is present in the response`, - { + test(`throws a validation error when an extra privilege is present in the response`, async () => { + const result = await checkPrivilegesAtSpaceTest({ spaceId: 'space_1', privilegeOrPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], esHasPrivilegesResponse: { @@ -246,13 +269,14 @@ describe('#atSpace', () => { }, }, }, - expectErrorThrown: true, - } - ); + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_1" fails because ["saved_object:bar-type/get" is not allowed]]]]` + ); + }); - checkPrivilegesAtSpaceTest( - `throws a validation error when privileges are missing in the response`, - { + test(`throws a validation error when privileges are missing in the response`, async () => { + const result = await checkPrivilegesAtSpaceTest({ spaceId: 'space_1', privilegeOrPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], esHasPrivilegesResponse: { @@ -267,82 +291,69 @@ describe('#atSpace', () => { }, }, }, - expectErrorThrown: true, - } - ); + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_1" fails because [child "saved_object:foo-type/get" fails because ["saved_object:foo-type/get" is required]]]]]` + ); + }); }); }); describe('#atSpaces', () => { - const checkPrivilegesAtSpacesTest = ( - description: string, - options: { - spaceIds: string[]; - privilegeOrPrivileges: string | string[]; - esHasPrivilegesResponse: HasPrivilegesResponse; - expectedResult?: any; - expectErrorThrown?: any; - } - ) => { - test(description, async () => { - const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( - options.esHasPrivilegesResponse - ); - const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( - mockActions, - mockClusterClient, - application - ); - const request = httpServerMock.createKibanaRequest(); - const checkPrivileges = checkPrivilegesWithRequest(request); - - let actualResult; - let errorThrown = null; - try { - actualResult = await checkPrivileges.atSpaces( - options.spaceIds, - options.privilegeOrPrivileges - ); - } catch (err) { - errorThrown = err; - } + const checkPrivilegesAtSpacesTest = async (options: { + spaceIds: string[]; + privilegeOrPrivileges: string | string[]; + esHasPrivilegesResponse: HasPrivilegesResponse; + }) => { + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( + options.esHasPrivilegesResponse + ); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( + mockActions, + mockClusterClient, + application + ); + const request = httpServerMock.createKibanaRequest(); + const checkPrivileges = checkPrivilegesWithRequest(request); - expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( - 'shield.hasPrivileges', - { - body: { - applications: [ - { - application, - resources: options.spaceIds.map(spaceId => `space:${spaceId}`), - privileges: uniq([ - mockActions.version, - mockActions.login, - ...(Array.isArray(options.privilegeOrPrivileges) - ? options.privilegeOrPrivileges - : [options.privilegeOrPrivileges]), - ]), - }, - ], - }, - } + let actualResult; + let errorThrown = null; + try { + actualResult = await checkPrivileges.atSpaces( + options.spaceIds, + options.privilegeOrPrivileges ); + } catch (err) { + errorThrown = err; + } - if (options.expectedResult) { - expect(errorThrown).toBeNull(); - expect(actualResult).toEqual(options.expectedResult); - } - - if (options.expectErrorThrown) { - expect(errorThrown).toMatchSnapshot(); - } + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.hasPrivileges', { + body: { + applications: [ + { + application, + resources: options.spaceIds.map(spaceId => `space:${spaceId}`), + privileges: uniq([ + mockActions.version, + mockActions.login, + ...(Array.isArray(options.privilegeOrPrivileges) + ? options.privilegeOrPrivileges + : [options.privilegeOrPrivileges]), + ]), + }, + ], + }, }); + + if (errorThrown) { + return errorThrown; + } + return actualResult; }; - checkPrivilegesAtSpacesTest( - 'successful when checking for login and user has login at both spaces', - { + test('successful when checking for login and user has login at both spaces', async () => { + const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], privilegeOrPrivileges: mockActions.login, esHasPrivilegesResponse: { @@ -361,24 +372,29 @@ describe('#atSpaces', () => { }, }, }, - expectedResult: { - hasAllRequested: true, - username: 'foo-username', - spacePrivileges: { - space_1: { - [mockActions.login]: true, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Array [ + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": "space_1", }, - space_2: { - [mockActions.login]: true, + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": "space_2", }, - }, - }, - } - ); + ], + "username": "foo-username", + } + `); + }); - checkPrivilegesAtSpacesTest( - 'failure when checking for login and user has login at only one space', - { + test('failure when checking for login and user has login at only one space', async () => { + const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], privilegeOrPrivileges: mockActions.login, esHasPrivilegesResponse: { @@ -397,24 +413,29 @@ describe('#atSpaces', () => { }, }, }, - expectedResult: { - hasAllRequested: false, - username: 'foo-username', - spacePrivileges: { - space_1: { - [mockActions.login]: true, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Array [ + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": "space_1", }, - space_2: { - [mockActions.login]: false, + Object { + "authorized": false, + "privilege": "mock-action:login", + "resource": "space_2", }, - }, - }, - } - ); + ], + "username": "foo-username", + } + `); + }); - checkPrivilegesAtSpacesTest( - `throws error when checking for login and user has login but doesn't have version`, - { + test(`throws error when checking for login and user has login but doesn't have version`, async () => { + const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], privilegeOrPrivileges: mockActions.login, esHasPrivilegesResponse: { @@ -433,38 +454,43 @@ describe('#atSpaces', () => { }, }, }, - expectErrorThrown: true, - } - ); - - checkPrivilegesAtSpacesTest(`throws error when Elasticsearch returns malformed response`, { - spaceIds: ['space_1', 'space_2'], - privilegeOrPrivileges: [ - `saved_object:${savedObjectTypes[0]}/get`, - `saved_object:${savedObjectTypes[1]}/get`, - ], - esHasPrivilegesResponse: { - has_all_requested: true, - username: 'foo-username', - application: { - [application]: { - 'space:space_1': { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: true, - }, - 'space:space_2': { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: true, + }); + expect(result).toMatchInlineSnapshot( + `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]` + ); + }); + + test(`throws error when Elasticsearch returns malformed response`, async () => { + const result = await checkPrivilegesAtSpacesTest({ + spaceIds: ['space_1', 'space_2'], + privilegeOrPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [`saved_object:${savedObjectTypes[0]}/get`]: true, + [`saved_object:${savedObjectTypes[1]}/get`]: true, + }, + 'space:space_2': { + [`saved_object:${savedObjectTypes[0]}/get`]: true, + [`saved_object:${savedObjectTypes[1]}/get`]: true, + }, }, }, }, - }, - expectErrorThrown: true, + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_1" fails because [child "mock-action:version" fails because ["mock-action:version" is required]]]]]` + ); }); - checkPrivilegesAtSpacesTest( - `successful when checking for two actions at two spaces and user has it all`, - { + test(`successful when checking for two actions at two spaces and user has it all`, async () => { + const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], privilegeOrPrivileges: [ `saved_object:${savedObjectTypes[0]}/get`, @@ -490,26 +516,39 @@ describe('#atSpaces', () => { }, }, }, - expectedResult: { - hasAllRequested: true, - username: 'foo-username', - spacePrivileges: { - space_1: { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: true, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", }, - space_2: { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: true, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", }, - }, - }, - } - ); + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_2", + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_2", + }, + ], + "username": "foo-username", + } + `); + }); - checkPrivilegesAtSpacesTest( - `failure when checking for two actions at two spaces and user has one action at one space`, - { + test(`failure when checking for two actions at two spaces and user has one action at one space`, async () => { + const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], privilegeOrPrivileges: [ `saved_object:${savedObjectTypes[0]}/get`, @@ -535,26 +574,39 @@ describe('#atSpaces', () => { }, }, }, - expectedResult: { - hasAllRequested: false, - username: 'foo-username', - spacePrivileges: { - space_1: { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: false, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", }, - space_2: { - [`saved_object:${savedObjectTypes[0]}/get`]: false, - [`saved_object:${savedObjectTypes[1]}/get`]: false, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", }, - }, - }, - } - ); + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": "space_2", + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": "space_2", + }, + ], + "username": "foo-username", + } + `); + }); - checkPrivilegesAtSpacesTest( - `failure when checking for two actions at two spaces and user has two actions at one space`, - { + test(`failure when checking for two actions at two spaces and user has two actions at one space`, async () => { + const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], privilegeOrPrivileges: [ `saved_object:${savedObjectTypes[0]}/get`, @@ -580,26 +632,39 @@ describe('#atSpaces', () => { }, }, }, - expectedResult: { - hasAllRequested: false, - username: 'foo-username', - spacePrivileges: { - space_1: { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: true, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", }, - space_2: { - [`saved_object:${savedObjectTypes[0]}/get`]: false, - [`saved_object:${savedObjectTypes[1]}/get`]: false, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", }, - }, - }, - } - ); + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": "space_2", + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": "space_2", + }, + ], + "username": "foo-username", + } + `); + }); - checkPrivilegesAtSpacesTest( - `failure when checking for two actions at two spaces and user has two actions at one space & one action at the other`, - { + test(`failure when checking for two actions at two spaces and user has two actions at one space & one action at the other`, async () => { + const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], privilegeOrPrivileges: [ `saved_object:${savedObjectTypes[0]}/get`, @@ -625,27 +690,40 @@ describe('#atSpaces', () => { }, }, }, - expectedResult: { - hasAllRequested: false, - username: 'foo-username', - spacePrivileges: { - space_1: { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: true, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_1", }, - space_2: { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: false, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": "space_1", }, - }, - }, - } - ); + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": "space_2", + }, + Object { + "authorized": false, + "privilege": "saved_object:bar-type/get", + "resource": "space_2", + }, + ], + "username": "foo-username", + } + `); + }); describe('with a malformed Elasticsearch response', () => { - checkPrivilegesAtSpacesTest( - `throws a validation error when an extra privilege is present in the response`, - { + test(`throws a validation error when an extra privilege is present in the response`, async () => { + const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], privilegeOrPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], esHasPrivilegesResponse: { @@ -668,13 +746,14 @@ describe('#atSpaces', () => { }, }, }, - expectErrorThrown: true, - } - ); + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_2" fails because ["space:space_2" is required]]]]` + ); + }); - checkPrivilegesAtSpacesTest( - `throws a validation error when privileges are missing in the response`, - { + test(`throws a validation error when privileges are missing in the response`, async () => { + const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], privilegeOrPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], esHasPrivilegesResponse: { @@ -695,13 +774,14 @@ describe('#atSpaces', () => { }, }, }, - expectErrorThrown: true, - } - ); + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_2" fails because ["space:space_2" is required]]]]` + ); + }); - checkPrivilegesAtSpacesTest( - `throws a validation error when an extra space is present in the response`, - { + test(`throws a validation error when an extra space is present in the response`, async () => { + const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], privilegeOrPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], esHasPrivilegesResponse: { @@ -727,13 +807,14 @@ describe('#atSpaces', () => { }, }, }, - expectErrorThrown: true, - } - ); + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because ["space:space_3" is not allowed]]]` + ); + }); - checkPrivilegesAtSpacesTest( - `throws a validation error when an a space is missing in the response`, - { + test(`throws a validation error when an a space is missing in the response`, async () => { + const result = await checkPrivilegesAtSpacesTest({ spaceIds: ['space_1', 'space_2'], privilegeOrPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], esHasPrivilegesResponse: { @@ -749,124 +830,127 @@ describe('#atSpaces', () => { }, }, }, - expectErrorThrown: true, - } - ); + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_2" fails because ["space:space_2" is required]]]]` + ); + }); }); }); describe('#globally', () => { - const checkPrivilegesGloballyTest = ( - description: string, - options: { - privilegeOrPrivileges: string | string[]; - esHasPrivilegesResponse: HasPrivilegesResponse; - expectedResult?: any; - expectErrorThrown?: any; + const checkPrivilegesGloballyTest = async (options: { + privilegeOrPrivileges: string | string[]; + esHasPrivilegesResponse: HasPrivilegesResponse; + }) => { + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( + options.esHasPrivilegesResponse + ); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( + mockActions, + mockClusterClient, + application + ); + const request = httpServerMock.createKibanaRequest(); + const checkPrivileges = checkPrivilegesWithRequest(request); + + let actualResult; + let errorThrown = null; + try { + actualResult = await checkPrivileges.globally(options.privilegeOrPrivileges); + } catch (err) { + errorThrown = err; } - ) => { - test(description, async () => { - const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( - options.esHasPrivilegesResponse - ); - const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( - mockActions, - mockClusterClient, - application - ); - const request = httpServerMock.createKibanaRequest(); - const checkPrivileges = checkPrivilegesWithRequest(request); - - let actualResult; - let errorThrown = null; - try { - actualResult = await checkPrivileges.globally(options.privilegeOrPrivileges); - } catch (err) { - errorThrown = err; - } - expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); - expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( - 'shield.hasPrivileges', - { - body: { - applications: [ - { - application, - resources: [GLOBAL_RESOURCE], - privileges: uniq([ - mockActions.version, - mockActions.login, - ...(Array.isArray(options.privilegeOrPrivileges) - ? options.privilegeOrPrivileges - : [options.privilegeOrPrivileges]), - ]), - }, - ], + expect(mockClusterClient.asScoped).toHaveBeenCalledWith(request); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.hasPrivileges', { + body: { + applications: [ + { + application, + resources: [GLOBAL_RESOURCE], + privileges: uniq([ + mockActions.version, + mockActions.login, + ...(Array.isArray(options.privilegeOrPrivileges) + ? options.privilegeOrPrivileges + : [options.privilegeOrPrivileges]), + ]), }, - } - ); - - if (options.expectedResult) { - expect(errorThrown).toBeNull(); - expect(actualResult).toEqual(options.expectedResult); - } - - if (options.expectErrorThrown) { - expect(errorThrown).toMatchSnapshot(); - } + ], + }, }); + + if (errorThrown) { + return errorThrown; + } + return actualResult; }; - checkPrivilegesGloballyTest('successful when checking for login and user has login', { - privilegeOrPrivileges: mockActions.login, - esHasPrivilegesResponse: { - has_all_requested: true, - username: 'foo-username', - application: { - [application]: { - [GLOBAL_RESOURCE]: { - [mockActions.login]: true, - [mockActions.version]: true, + test('successful when checking for login and user has login', async () => { + const result = await checkPrivilegesGloballyTest({ + privilegeOrPrivileges: mockActions.login, + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: true, + [mockActions.version]: true, + }, }, }, }, - }, - expectedResult: { - hasAllRequested: true, - username: 'foo-username', - privileges: { - [mockActions.login]: true, - }, - }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Array [ + Object { + "authorized": true, + "privilege": "mock-action:login", + "resource": undefined, + }, + ], + "username": "foo-username", + } + `); }); - checkPrivilegesGloballyTest(`failure when checking for login and user doesn't have login`, { - privilegeOrPrivileges: mockActions.login, - esHasPrivilegesResponse: { - has_all_requested: false, - username: 'foo-username', - application: { - [application]: { - [GLOBAL_RESOURCE]: { - [mockActions.login]: false, - [mockActions.version]: true, + test(`failure when checking for login and user doesn't have login`, async () => { + const result = await checkPrivilegesGloballyTest({ + privilegeOrPrivileges: mockActions.login, + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: false, + [mockActions.version]: true, + }, }, }, }, - }, - expectedResult: { - hasAllRequested: false, - username: 'foo-username', - privileges: { - [mockActions.login]: false, - }, - }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Array [ + Object { + "authorized": false, + "privilege": "mock-action:login", + "resource": undefined, + }, + ], + "username": "foo-username", + } + `); }); - checkPrivilegesGloballyTest( - `throws error when checking for login and user has login but doesn't have version`, - { + test(`throws error when checking for login and user has login but doesn't have version`, async () => { + const result = await checkPrivilegesGloballyTest({ privilegeOrPrivileges: mockActions.login, esHasPrivilegesResponse: { has_all_requested: false, @@ -880,92 +964,121 @@ describe('#globally', () => { }, }, }, - expectErrorThrown: true, - } - ); - - checkPrivilegesGloballyTest(`throws error when Elasticsearch returns malformed response`, { - privilegeOrPrivileges: [ - `saved_object:${savedObjectTypes[0]}/get`, - `saved_object:${savedObjectTypes[1]}/get`, - ], - esHasPrivilegesResponse: { - has_all_requested: false, - username: 'foo-username', - application: { - [application]: { - [GLOBAL_RESOURCE]: { - [`saved_object:${savedObjectTypes[0]}/get`]: false, - [`saved_object:${savedObjectTypes[1]}/get`]: true, + }); + expect(result).toMatchInlineSnapshot( + `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]` + ); + }); + + test(`throws error when Elasticsearch returns malformed response`, async () => { + const result = await checkPrivilegesGloballyTest({ + privilegeOrPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [`saved_object:${savedObjectTypes[0]}/get`]: false, + [`saved_object:${savedObjectTypes[1]}/get`]: true, + }, }, }, }, - }, - expectErrorThrown: true, + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because [child "mock-action:version" fails because ["mock-action:version" is required]]]]]` + ); }); - checkPrivilegesGloballyTest(`successful when checking for two actions and the user has both`, { - privilegeOrPrivileges: [ - `saved_object:${savedObjectTypes[0]}/get`, - `saved_object:${savedObjectTypes[1]}/get`, - ], - esHasPrivilegesResponse: { - has_all_requested: true, - username: 'foo-username', - application: { - [application]: { - [GLOBAL_RESOURCE]: { - [mockActions.login]: true, - [mockActions.version]: true, - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: true, + test(`successful when checking for two actions and the user has both`, async () => { + const result = await checkPrivilegesGloballyTest({ + privilegeOrPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: true, + [`saved_object:${savedObjectTypes[1]}/get`]: true, + }, }, }, }, - }, - expectedResult: { - hasAllRequested: true, - username: 'foo-username', - privileges: { - [`saved_object:${savedObjectTypes[0]}/get`]: true, - [`saved_object:${savedObjectTypes[1]}/get`]: true, - }, - }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": true, + "privileges": Array [ + Object { + "authorized": true, + "privilege": "saved_object:foo-type/get", + "resource": undefined, + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": undefined, + }, + ], + "username": "foo-username", + } + `); }); - checkPrivilegesGloballyTest(`failure when checking for two actions and the user has only one`, { - privilegeOrPrivileges: [ - `saved_object:${savedObjectTypes[0]}/get`, - `saved_object:${savedObjectTypes[1]}/get`, - ], - esHasPrivilegesResponse: { - has_all_requested: false, - username: 'foo-username', - application: { - [application]: { - [GLOBAL_RESOURCE]: { - [mockActions.login]: true, - [mockActions.version]: true, - [`saved_object:${savedObjectTypes[0]}/get`]: false, - [`saved_object:${savedObjectTypes[1]}/get`]: true, + test(`failure when checking for two actions and the user has only one`, async () => { + const result = await checkPrivilegesGloballyTest({ + privilegeOrPrivileges: [ + `saved_object:${savedObjectTypes[0]}/get`, + `saved_object:${savedObjectTypes[1]}/get`, + ], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: true, + [mockActions.version]: true, + [`saved_object:${savedObjectTypes[0]}/get`]: false, + [`saved_object:${savedObjectTypes[1]}/get`]: true, + }, }, }, }, - }, - expectedResult: { - hasAllRequested: false, - username: 'foo-username', - privileges: { - [`saved_object:${savedObjectTypes[0]}/get`]: false, - [`saved_object:${savedObjectTypes[1]}/get`]: true, - }, - }, + }); + expect(result).toMatchInlineSnapshot(` + Object { + "hasAllRequested": false, + "privileges": Array [ + Object { + "authorized": false, + "privilege": "saved_object:foo-type/get", + "resource": undefined, + }, + Object { + "authorized": true, + "privilege": "saved_object:bar-type/get", + "resource": undefined, + }, + ], + "username": "foo-username", + } + `); }); describe('with a malformed Elasticsearch response', () => { - checkPrivilegesGloballyTest( - `throws a validation error when an extra privilege is present in the response`, - { + test(`throws a validation error when an extra privilege is present in the response`, async () => { + const result = await checkPrivilegesGloballyTest({ privilegeOrPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], esHasPrivilegesResponse: { has_all_requested: false, @@ -981,13 +1094,14 @@ describe('#globally', () => { }, }, }, - expectErrorThrown: true, - } - ); + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because ["saved_object:bar-type/get" is not allowed]]]]` + ); + }); - checkPrivilegesGloballyTest( - `throws a validation error when privileges are missing in the response`, - { + test(`throws a validation error when privileges are missing in the response`, async () => { + const result = await checkPrivilegesGloballyTest({ privilegeOrPrivileges: [`saved_object:${savedObjectTypes[0]}/get`], esHasPrivilegesResponse: { has_all_requested: false, @@ -1001,8 +1115,10 @@ describe('#globally', () => { }, }, }, - expectErrorThrown: true, - } - ); + }); + expect(result).toMatchInlineSnapshot( + `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because [child "saved_object:foo-type/get" fails because ["saved_object:foo-type/get" is required]]]]]` + ); + }); }); }); diff --git a/x-pack/plugins/security/server/authorization/check_privileges.ts b/x-pack/plugins/security/server/authorization/check_privileges.ts index 3ef7a8f29a0bf2..177a49d6defe91 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.ts @@ -16,32 +16,17 @@ interface CheckPrivilegesActions { version: string; } -interface CheckPrivilegesAtResourcesResponse { +export interface CheckPrivilegesResponse { hasAllRequested: boolean; username: string; - resourcePrivileges: { - [resource: string]: { - [privilege: string]: boolean; - }; - }; -} - -export interface CheckPrivilegesAtResourceResponse { - hasAllRequested: boolean; - username: string; - privileges: { - [privilege: string]: boolean; - }; -} - -export interface CheckPrivilegesAtSpacesResponse { - hasAllRequested: boolean; - username: string; - spacePrivileges: { - [spaceId: string]: { - [privilege: string]: boolean; - }; - }; + privileges: Array<{ + /** + * If this attribute is undefined, this element is a privilege for the global resource. + */ + resource?: string; + privilege: string; + authorized: boolean; + }>; } export type CheckPrivilegesWithRequest = (request: KibanaRequest) => CheckPrivileges; @@ -50,12 +35,12 @@ export interface CheckPrivileges { atSpace( spaceId: string, privilegeOrPrivileges: string | string[] - ): Promise; + ): Promise; atSpaces( spaceIds: string[], privilegeOrPrivileges: string | string[] - ): Promise; - globally(privilegeOrPrivileges: string | string[]): Promise; + ): Promise; + globally(privilegeOrPrivileges: string | string[]): Promise; } export function checkPrivilegesWithRequestFactory( @@ -75,7 +60,7 @@ export function checkPrivilegesWithRequestFactory( const checkPrivilegesAtResources = async ( resources: string[], privilegeOrPrivileges: string | string[] - ): Promise => { + ): Promise => { const privileges = Array.isArray(privilegeOrPrivileges) ? privilegeOrPrivileges : [privilegeOrPrivileges]; @@ -106,55 +91,43 @@ export function checkPrivilegesWithRequestFactory( ); } + // we need to filter out the non requested privileges from the response + const resourcePrivileges = transform(applicationPrivilegesResponse, (result, value, key) => { + result[key!] = pick(value, privileges); + }) as HasPrivilegesResponseApplication; + const privilegeArray = Object.entries(resourcePrivileges) + .map(([key, val]) => { + // we need to turn the resource responses back into the space ids + const resource = + key !== GLOBAL_RESOURCE ? ResourceSerializer.deserializeSpaceResource(key!) : undefined; + return Object.entries(val).map(([privilege, authorized]) => ({ + resource, + privilege, + authorized, + })); + }) + .flat(); + return { hasAllRequested: hasPrivilegesResponse.has_all_requested, username: hasPrivilegesResponse.username, - // we need to filter out the non requested privileges from the response - resourcePrivileges: transform(applicationPrivilegesResponse, (result, value, key) => { - result[key!] = pick(value, privileges); - }), - }; - }; - - const checkPrivilegesAtResource = async ( - resource: string, - privilegeOrPrivileges: string | string[] - ) => { - const { hasAllRequested, username, resourcePrivileges } = await checkPrivilegesAtResources( - [resource], - privilegeOrPrivileges - ); - return { - hasAllRequested, - username, - privileges: resourcePrivileges[resource], + privileges: privilegeArray, }; }; return { async atSpace(spaceId: string, privilegeOrPrivileges: string | string[]) { const spaceResource = ResourceSerializer.serializeSpaceResource(spaceId); - return await checkPrivilegesAtResource(spaceResource, privilegeOrPrivileges); + return await checkPrivilegesAtResources([spaceResource], privilegeOrPrivileges); }, async atSpaces(spaceIds: string[], privilegeOrPrivileges: string | string[]) { const spaceResources = spaceIds.map(spaceId => ResourceSerializer.serializeSpaceResource(spaceId) ); - const { hasAllRequested, username, resourcePrivileges } = await checkPrivilegesAtResources( - spaceResources, - privilegeOrPrivileges - ); - return { - hasAllRequested, - username, - // we need to turn the resource responses back into the space ids - spacePrivileges: transform(resourcePrivileges, (result, value, key) => { - result[ResourceSerializer.deserializeSpaceResource(key!)] = value; - }), - }; + return await checkPrivilegesAtResources(spaceResources, privilegeOrPrivileges); }, async globally(privilegeOrPrivileges: string | string[]) { - return await checkPrivilegesAtResource(GLOBAL_RESOURCE, privilegeOrPrivileges); + return await checkPrivilegesAtResources([GLOBAL_RESOURCE], privilegeOrPrivileges); }, }; }; diff --git a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts index 0377dd06eb6692..6014bad739e776 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts @@ -6,11 +6,11 @@ import { KibanaRequest } from '../../../../../src/core/server'; import { SpacesService } from '../plugin'; -import { CheckPrivilegesAtResourceResponse, CheckPrivilegesWithRequest } from './check_privileges'; +import { CheckPrivilegesResponse, CheckPrivilegesWithRequest } from './check_privileges'; export type CheckPrivilegesDynamically = ( privilegeOrPrivileges: string | string[] -) => Promise; +) => Promise; export type CheckPrivilegesDynamicallyWithRequest = ( request: KibanaRequest diff --git a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts index 4618e8e6641fcf..43b3824500579c 100644 --- a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts @@ -7,57 +7,113 @@ import { checkSavedObjectsPrivilegesWithRequestFactory } from './check_saved_objects_privileges'; import { httpServerMock } from '../../../../../src/core/server/mocks'; +import { CheckPrivileges, CheckPrivilegesWithRequest } from './check_privileges'; +import { SpacesService } from '../plugin'; -test(`checkPrivileges.atSpace when spaces is enabled`, async () => { - const expectedResult = Symbol(); - const spaceId = 'foo-space'; - const mockCheckPrivileges = { - atSpace: jest.fn().mockReturnValue(expectedResult), - }; - const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const request = httpServerMock.createKibanaRequest(); - const privilegeOrPrivileges = ['foo', 'bar']; - const mockSpacesService = { - getSpaceId: jest.fn(), - namespaceToSpaceId: jest.fn().mockReturnValue(spaceId), - }; +let mockCheckPrivileges: jest.Mocked; +let mockCheckPrivilegesWithRequest: jest.Mocked; +let mockSpacesService: jest.Mocked | undefined; +const request = httpServerMock.createKibanaRequest(); - const checkSavedObjectsPrivileges = checkSavedObjectsPrivilegesWithRequestFactory( +const createFactory = () => + checkSavedObjectsPrivilegesWithRequestFactory( mockCheckPrivilegesWithRequest, () => mockSpacesService )(request); - const namespace = 'foo'; - - const result = await checkSavedObjectsPrivileges(privilegeOrPrivileges, namespace); +beforeEach(() => { + mockCheckPrivileges = { + atSpace: jest.fn(), + atSpaces: jest.fn(), + globally: jest.fn(), + }; + mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - expect(result).toBe(expectedResult); - expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, privilegeOrPrivileges); - expect(mockSpacesService.namespaceToSpaceId).toBeCalledWith(namespace); + mockSpacesService = { + getSpaceId: jest.fn(), + namespaceToSpaceId: jest.fn().mockImplementation((namespace: string) => `${namespace}-id`), + }; }); -test(`checkPrivileges.globally when spaces is disabled`, async () => { - const expectedResult = Symbol(); - const mockCheckPrivileges = { - globally: jest.fn().mockReturnValue(expectedResult), - }; - const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); +describe('#checkSavedObjectsPrivileges', () => { + const actions = ['foo', 'bar']; + const namespace1 = 'baz'; + const namespace2 = 'qux'; - const request = httpServerMock.createKibanaRequest(); + describe('when checking multiple namespaces', () => { + const namespaces = [namespace1, namespace2]; - const privilegeOrPrivileges = ['foo', 'bar']; + test(`throws an error when Spaces is disabled`, async () => { + mockSpacesService = undefined; + const checkSavedObjectsPrivileges = createFactory(); - const checkSavedObjectsPrivileges = checkSavedObjectsPrivilegesWithRequestFactory( - mockCheckPrivilegesWithRequest, - () => undefined - )(request); + await expect( + checkSavedObjectsPrivileges(actions, namespaces) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Can't check saved object privileges for multiple namespaces if Spaces is disabled"` + ); + }); + + test(`throws an error when using an empty namespaces array`, async () => { + const checkSavedObjectsPrivileges = createFactory(); + + await expect( + checkSavedObjectsPrivileges(actions, []) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Can't check saved object privileges for 0 namespaces"` + ); + }); + + test(`uses checkPrivileges.atSpaces when spaces is enabled`, async () => { + const expectedResult = Symbol(); + mockCheckPrivileges.atSpaces.mockReturnValue(expectedResult as any); + const checkSavedObjectsPrivileges = createFactory(); + + const result = await checkSavedObjectsPrivileges(actions, namespaces); + + expect(result).toBe(expectedResult); + expect(mockSpacesService!.namespaceToSpaceId).toHaveBeenCalledTimes(2); + expect(mockSpacesService!.namespaceToSpaceId).toHaveBeenNthCalledWith(1, namespace1); + expect(mockSpacesService!.namespaceToSpaceId).toHaveBeenNthCalledWith(2, namespace2); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledTimes(1); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivileges.atSpaces).toHaveBeenCalledTimes(1); + const spaceIds = mockSpacesService!.namespaceToSpaceId.mock.results.map(x => x.value); + expect(mockCheckPrivileges.atSpaces).toHaveBeenCalledWith(spaceIds, actions); + }); + }); + + describe('when checking a single namespace', () => { + test(`uses checkPrivileges.atSpace when Spaces is enabled`, async () => { + const expectedResult = Symbol(); + mockCheckPrivileges.atSpace.mockReturnValue(expectedResult as any); + const checkSavedObjectsPrivileges = createFactory(); + + const result = await checkSavedObjectsPrivileges(actions, namespace1); + + expect(result).toBe(expectedResult); + expect(mockSpacesService!.namespaceToSpaceId).toHaveBeenCalledTimes(1); + expect(mockSpacesService!.namespaceToSpaceId).toHaveBeenCalledWith(namespace1); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledTimes(1); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledTimes(1); + const spaceId = mockSpacesService!.namespaceToSpaceId.mock.results[0].value; + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, actions); + }); - const namespace = 'foo'; + test(`uses checkPrivileges.globally when Spaces is disabled`, async () => { + const expectedResult = Symbol(); + mockCheckPrivileges.globally.mockReturnValue(expectedResult as any); + mockSpacesService = undefined; + const checkSavedObjectsPrivileges = createFactory(); - const result = await checkSavedObjectsPrivileges(privilegeOrPrivileges, namespace); + const result = await checkSavedObjectsPrivileges(actions, namespace1); - expect(result).toBe(expectedResult); - expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivileges.globally).toHaveBeenCalledWith(privilegeOrPrivileges); + expect(result).toBe(expectedResult); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledTimes(1); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivileges.globally).toHaveBeenCalledTimes(1); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith(actions); + }); + }); }); diff --git a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts index 02958fe265efac..43140143a17730 100644 --- a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts +++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts @@ -6,32 +6,44 @@ import { KibanaRequest } from '../../../../../src/core/server'; import { SpacesService } from '../plugin'; -import { CheckPrivilegesAtResourceResponse, CheckPrivilegesWithRequest } from './check_privileges'; +import { CheckPrivilegesWithRequest, CheckPrivilegesResponse } from './check_privileges'; export type CheckSavedObjectsPrivilegesWithRequest = ( request: KibanaRequest ) => CheckSavedObjectsPrivileges; + export type CheckSavedObjectsPrivileges = ( actions: string | string[], - namespace?: string -) => Promise; + namespaceOrNamespaces?: string | string[] +) => Promise; export const checkSavedObjectsPrivilegesWithRequestFactory = ( checkPrivilegesWithRequest: CheckPrivilegesWithRequest, getSpacesService: () => SpacesService | undefined ): CheckSavedObjectsPrivilegesWithRequest => { - return function checkSavedObjectsPrivilegesWithRequest(request: KibanaRequest) { + return function checkSavedObjectsPrivilegesWithRequest( + request: KibanaRequest + ): CheckSavedObjectsPrivileges { return async function checkSavedObjectsPrivileges( actions: string | string[], - namespace?: string + namespaceOrNamespaces?: string | string[] ) { const spacesService = getSpacesService(); - return spacesService - ? await checkPrivilegesWithRequest(request).atSpace( - spacesService.namespaceToSpaceId(namespace), - actions - ) - : await checkPrivilegesWithRequest(request).globally(actions); + if (Array.isArray(namespaceOrNamespaces)) { + if (spacesService === undefined) { + throw new Error( + `Can't check saved object privileges for multiple namespaces if Spaces is disabled` + ); + } else if (!namespaceOrNamespaces.length) { + throw new Error(`Can't check saved object privileges for 0 namespaces`); + } + const spaceIds = namespaceOrNamespaces.map(x => spacesService.namespaceToSpaceId(x)); + return await checkPrivilegesWithRequest(request).atSpaces(spaceIds, actions); + } else if (spacesService) { + const spaceId = spacesService.namespaceToSpaceId(namespaceOrNamespaces); + return await checkPrivilegesWithRequest(request).atSpace(spaceId, actions); + } + return await checkPrivilegesWithRequest(request).globally(actions); }; }; }; diff --git a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts index 912ae60e12065d..ea97a1b3b590c3 100644 --- a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts @@ -11,7 +11,9 @@ import { httpServerMock, loggingServiceMock } from '../../../../../src/core/serv import { authorizationMock } from './index.mock'; import { Feature } from '../../../features/server'; -type MockAuthzOptions = { rejectCheckPrivileges: any } | { resolveCheckPrivileges: any }; +type MockAuthzOptions = + | { rejectCheckPrivileges: any } + | { resolveCheckPrivileges: { privileges: Array<{ privilege: string; authorized: boolean }> } }; const actions = new Actions('1.0.0-zeta1'); const mockRequest = httpServerMock.createKibanaRequest(); @@ -26,7 +28,8 @@ const createMockAuthz = (options: MockAuthzOptions) => { throw options.rejectCheckPrivileges; } - expect(checkActions).toEqual(Object.keys(options.resolveCheckPrivileges.privileges)); + const expected = options.resolveCheckPrivileges.privileges.map(x => x.privilege); + expect(checkActions).toEqual(expected); return options.resolveCheckPrivileges; }); }); @@ -226,17 +229,17 @@ describe('usingPrivileges', () => { test(`disables ui capabilities when they don't have privileges`, async () => { const mockAuthz = createMockAuthz({ resolveCheckPrivileges: { - privileges: { - [actions.ui.get('navLinks', 'foo')]: true, - [actions.ui.get('navLinks', 'bar')]: false, - [actions.ui.get('navLinks', 'quz')]: false, - [actions.ui.get('management', 'kibana', 'indices')]: true, - [actions.ui.get('management', 'kibana', 'settings')]: false, - [actions.ui.get('fooFeature', 'foo')]: true, - [actions.ui.get('fooFeature', 'bar')]: false, - [actions.ui.get('barFeature', 'foo')]: true, - [actions.ui.get('barFeature', 'bar')]: false, - }, + privileges: [ + { privilege: actions.ui.get('navLinks', 'foo'), authorized: true }, + { privilege: actions.ui.get('navLinks', 'bar'), authorized: false }, + { privilege: actions.ui.get('navLinks', 'quz'), authorized: false }, + { privilege: actions.ui.get('management', 'kibana', 'indices'), authorized: true }, + { privilege: actions.ui.get('management', 'kibana', 'settings'), authorized: false }, + { privilege: actions.ui.get('fooFeature', 'foo'), authorized: true }, + { privilege: actions.ui.get('fooFeature', 'bar'), authorized: false }, + { privilege: actions.ui.get('barFeature', 'foo'), authorized: true }, + { privilege: actions.ui.get('barFeature', 'bar'), authorized: false }, + ], }, }); @@ -314,15 +317,15 @@ describe('usingPrivileges', () => { test(`doesn't re-enable disabled uiCapabilities`, async () => { const mockAuthz = createMockAuthz({ resolveCheckPrivileges: { - privileges: { - [actions.ui.get('navLinks', 'foo')]: true, - [actions.ui.get('navLinks', 'bar')]: true, - [actions.ui.get('management', 'kibana', 'indices')]: true, - [actions.ui.get('fooFeature', 'foo')]: true, - [actions.ui.get('fooFeature', 'bar')]: true, - [actions.ui.get('barFeature', 'foo')]: true, - [actions.ui.get('barFeature', 'bar')]: true, - }, + privileges: [ + { privilege: actions.ui.get('navLinks', 'foo'), authorized: true }, + { privilege: actions.ui.get('navLinks', 'bar'), authorized: true }, + { privilege: actions.ui.get('management', 'kibana', 'indices'), authorized: true }, + { privilege: actions.ui.get('fooFeature', 'foo'), authorized: true }, + { privilege: actions.ui.get('fooFeature', 'bar'), authorized: true }, + { privilege: actions.ui.get('barFeature', 'foo'), authorized: true }, + { privilege: actions.ui.get('barFeature', 'bar'), authorized: true }, + ], }, }); diff --git a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts index be26f52fbf7562..f0f1a42ad0bd54 100644 --- a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts @@ -9,7 +9,7 @@ import { UICapabilities } from 'ui/capabilities'; import { KibanaRequest, Logger } from '../../../../../src/core/server'; import { Feature } from '../../../features/server'; -import { CheckPrivilegesAtResourceResponse } from './check_privileges'; +import { CheckPrivilegesResponse } from './check_privileges'; import { Authorization } from './index'; export function disableUICapabilitiesFactory( @@ -77,7 +77,7 @@ export function disableUICapabilitiesFactory( [] ); - let checkPrivilegesResponse: CheckPrivilegesAtResourceResponse; + let checkPrivilegesResponse: CheckPrivilegesResponse; try { const checkPrivilegesDynamically = authz.checkPrivilegesDynamicallyWithRequest(request); checkPrivilegesResponse = await checkPrivilegesDynamically(uiActions); @@ -105,7 +105,9 @@ export function disableUICapabilitiesFactory( } const action = authz.actions.ui.get(featureId, ...uiCapabilityParts); - return checkPrivilegesResponse.privileges[action] === true; + return checkPrivilegesResponse.privileges.some( + x => x.privilege === action && x.authorized === true + ); }; return mapValues(uiCapabilities, (featureUICapabilities, featureId) => { diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 032d231fe798fc..68acf68f46109d 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -149,6 +149,7 @@ export class Plugin { auditLogger: new SecurityAuditLogger(() => this.getLegacyAPI().auditLogger), authz, savedObjects: core.savedObjects, + getSpacesService: this.getSpacesService, }); core.capabilities.registerSwitcher(authz.disableUnauthorizedCapabilities); diff --git a/x-pack/plugins/security/server/saved_objects/index.ts b/x-pack/plugins/security/server/saved_objects/index.ts index 59547295628479..7dac745fcf84b3 100644 --- a/x-pack/plugins/security/server/saved_objects/index.ts +++ b/x-pack/plugins/security/server/saved_objects/index.ts @@ -13,14 +13,21 @@ import { import { SecureSavedObjectsClientWrapper } from './secure_saved_objects_client_wrapper'; import { Authorization } from '../authorization'; import { SecurityAuditLogger } from '../audit'; +import { SpacesService } from '../plugin'; interface SetupSavedObjectsParams { auditLogger: SecurityAuditLogger; authz: Pick; savedObjects: CoreSetup['savedObjects']; + getSpacesService(): SpacesService | undefined; } -export function setupSavedObjects({ auditLogger, authz, savedObjects }: SetupSavedObjectsParams) { +export function setupSavedObjects({ + auditLogger, + authz, + savedObjects, + getSpacesService, +}: SetupSavedObjectsParams) { const getKibanaRequest = (request: KibanaRequest | LegacyRequest) => request instanceof KibanaRequest ? request : KibanaRequest.from(request); @@ -44,6 +51,7 @@ export function setupSavedObjects({ auditLogger, authz, savedObjects }: SetupSav kibanaRequest ), errors: SavedObjectsClient.errors, + getSpacesService, }) : client; }); diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index 3c04508e3a74ac..3c4034e07f9956 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -9,6 +9,11 @@ import { Actions } from '../authorization'; import { securityAuditLoggerMock } from '../audit/index.mock'; import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { SavedObjectsClientContract } from 'kibana/server'; +import { SavedObjectActions } from '../authorization/actions/saved_object'; + +let clientOpts: ReturnType; +let client: SecureSavedObjectsClientWrapper; +const USERNAME = Symbol(); const createSecureSavedObjectsClientWrapperOptions = () => { const actions = new Actions('some-version'); @@ -22,810 +27,677 @@ const createSecureSavedObjectsClientWrapperOptions = () => { const errors = ({ decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError), decorateGeneralError: jest.fn().mockReturnValue(generalError), + isNotFoundError: jest.fn().mockReturnValue(false), } as unknown) as jest.Mocked; + const getSpacesService = jest.fn().mockReturnValue(true); return { actions, baseClient: savedObjectsClientMock.create(), checkSavedObjectsPrivilegesAsCurrentUser: jest.fn(), errors, + getSpacesService, auditLogger: securityAuditLoggerMock.create(), forbiddenError, generalError, }; }; -describe('#errors', () => { - test(`assigns errors from constructor to .errors`, () => { - const options = createSecureSavedObjectsClientWrapperOptions(); - const client = new SecureSavedObjectsClientWrapper(options); +const expectGeneralError = async (fn: Function, args: Record) => { + // mock the checkPrivileges.globally rejection + const rejection = new Error('An actual error would happen here'); + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(rejection); + + await expect(fn.bind(client)(...Object.values(args))).rejects.toThrowError( + clientOpts.generalError + ); + expect(clientOpts.errors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); +}; + +/** + * Fails the first authorization check, passes any others + * Requires that function args are passed in as key/value pairs + * The argument properties must be in the correct order to be spread properly + */ +const expectForbiddenError = async (fn: Function, args: Record) => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure + ); + + await expect(fn.bind(client)(...Object.values(args))).rejects.toThrowError( + clientOpts.forbiddenError + ); + const getCalls = (clientOpts.actions.savedObject.get as jest.MockedFunction< + SavedObjectActions['get'] + >).mock.calls; + const actions = clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mock.calls[0][0]; + const spaceId = args.options?.namespace || 'default'; + + const ACTION = getCalls[0][1]; + const types = getCalls.map(x => x[0]); + const missing = [{ spaceId, privilege: actions[0] }]; // if there was more than one type, only the first type was unauthorized + const spaceIds = [spaceId]; + + expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + USERNAME, + ACTION, + types, + spaceIds, + missing, + args + ); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); +}; + +const expectSuccess = async (fn: Function, args: Record) => { + const result = await fn.bind(client)(...Object.values(args)); + const getCalls = (clientOpts.actions.savedObject.get as jest.MockedFunction< + SavedObjectActions['get'] + >).mock.calls; + const ACTION = getCalls[0][1]; + const types = getCalls.map(x => x[0]); + const spaceIds = [args.options?.namespace || 'default']; + + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + USERNAME, + ACTION, + types, + spaceIds, + args + ); + return result; +}; + +const expectPrivilegeCheck = async (fn: Function, args: Record) => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure + ); + + await expect(fn.bind(client)(...Object.values(args))).rejects.toThrow(); // test is simpler with error case + const getResults = (clientOpts.actions.savedObject.get as jest.MockedFunction< + SavedObjectActions['get'] + >).mock.results; + const actions = getResults.map(x => x.value); + + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(1); + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + actions, + args.options?.namespace + ); +}; + +const expectObjectNamespaceFiltering = async (fn: Function, args: Record) => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( + getMockCheckPrivilegesSuccess // privilege check for authorization + ); + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure // privilege check for namespace filtering + ); + + const authorizedNamespace = args.options.namespace || 'default'; + const namespaces = ['some-other-namespace', authorizedNamespace]; + const returnValue = { namespaces, foo: 'bar' }; + // we don't know which base client method will be called; mock them all + clientOpts.baseClient.create.mockReturnValue(returnValue as any); + clientOpts.baseClient.get.mockReturnValue(returnValue as any); + clientOpts.baseClient.update.mockReturnValue(returnValue as any); + + const result = await fn.bind(client)(...Object.values(args)); + expect(result).toEqual(expect.objectContaining({ namespaces: [authorizedNamespace, '?'] })); + + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2); + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenLastCalledWith( + 'login:', + namespaces + ); +}; + +const expectObjectsNamespaceFiltering = async (fn: Function, args: Record) => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( + getMockCheckPrivilegesSuccess // privilege check for authorization + ); + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure // privilege check for namespace filtering + ); + + const authorizedNamespace = args.options.namespace || 'default'; + const returnValue = { + saved_objects: [ + { namespaces: ['foo'] }, + { namespaces: [authorizedNamespace] }, + { namespaces: ['foo', authorizedNamespace] }, + ], + }; + + // we don't know which base client method will be called; mock them all + clientOpts.baseClient.bulkCreate.mockReturnValue(returnValue as any); + clientOpts.baseClient.bulkGet.mockReturnValue(returnValue as any); + clientOpts.baseClient.bulkUpdate.mockReturnValue(returnValue as any); + clientOpts.baseClient.find.mockReturnValue(returnValue as any); + + const result = await fn.bind(client)(...Object.values(args)); + expect(result).toEqual( + expect.objectContaining({ + saved_objects: [ + { namespaces: ['?'] }, + { namespaces: [authorizedNamespace] }, + { namespaces: [authorizedNamespace, '?'] }, + ], + }) + ); + + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2); + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenLastCalledWith('login:', [ + 'foo', + authorizedNamespace, + ]); +}; + +function getMockCheckPrivilegesSuccess(actions: string | string[], namespaces?: string | string[]) { + const _namespaces = Array.isArray(namespaces) ? namespaces : [namespaces || 'default']; + const _actions = Array.isArray(actions) ? actions : [actions]; + return { + hasAllRequested: true, + username: USERNAME, + privileges: _namespaces + .map(resource => + _actions.map(action => ({ + resource, + privilege: action, + authorized: true, + })) + ) + .flat(), + }; +} + +/** + * Fails the authorization check for the first privilege, and passes any others + * This check may be for an action for two different types in the same namespace + * Or, it may be for an action for the same type in two different namespaces + * Either way, the first privilege check returned is false, and any others return true + */ +function getMockCheckPrivilegesFailure(actions: string | string[], namespaces?: string | string[]) { + const _namespaces = Array.isArray(namespaces) ? namespaces : [namespaces || 'default']; + const _actions = Array.isArray(actions) ? actions : [actions]; + return { + hasAllRequested: false, + username: USERNAME, + privileges: _namespaces + .map((resource, idxa) => + _actions.map((action, idxb) => ({ + resource, + privilege: action, + authorized: idxa > 0 || idxb > 0, + })) + ) + .flat(), + }; +} + +/** + * Before each test, create the Client with its Options + */ +beforeEach(() => { + clientOpts = createSecureSavedObjectsClientWrapperOptions(); + client = new SecureSavedObjectsClientWrapper(clientOpts); + + // succeed privilege checks by default + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesSuccess + ); +}); + +describe('#addToNamespaces', () => { + const type = 'foo'; + const id = `${type}-id`; + const newNs1 = 'foo-namespace'; + const newNs2 = 'bar-namespace'; + const namespaces = [newNs1, newNs2]; + const currentNs = 'default'; + const privilege1 = `mock-saved_object:${type}/create`; + const privilege2 = `mock-saved_object:${type}/update`; + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + await expectGeneralError(client.addToNamespaces, { type, id, namespaces }); + }); + + test(`throws decorated ForbiddenError when unauthorized to create in new space`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure + ); + + await expect(client.addToNamespaces(type, id, namespaces)).rejects.toThrowError( + clientOpts.forbiddenError + ); + + expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + USERNAME, + 'addToNamespacesCreate', + [type], + namespaces.sort(), + [{ privilege: privilege1, spaceId: newNs1 }], + { id, type, namespaces, options: {} } + ); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized to update in current space`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( + getMockCheckPrivilegesSuccess // create + ); + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure // update + ); + + await expect(client.addToNamespaces(type, id, namespaces)).rejects.toThrowError( + clientOpts.forbiddenError + ); + + expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect( + clientOpts.auditLogger.savedObjectsAuthorizationFailure + ).toHaveBeenLastCalledWith( + USERNAME, + 'addToNamespacesUpdate', + [type], + [currentNs], + [{ privilege: privilege2, spaceId: currentNs }], + { id, type, namespaces, options: {} } + ); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); + }); + + test(`returns result of baseClient.addToNamespaces when authorized`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.addToNamespaces.mockReturnValue(apiCallReturnValue as any); + + const result = await client.addToNamespaces(type, id, namespaces); + expect(result).toBe(apiCallReturnValue); + + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(2); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith( + 1, + USERNAME, + 'addToNamespacesCreate', // action for privilege check is 'create', but auditAction is 'addToNamespacesCreate' + [type], + namespaces.sort(), + { type, id, namespaces, options: {} } + ); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith( + 2, + USERNAME, + 'addToNamespacesUpdate', // action for privilege check is 'update', but auditAction is 'addToNamespacesUpdate' + [type], + [currentNs], + { type, id, namespaces, options: {} } + ); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( + getMockCheckPrivilegesSuccess // create + ); + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure // update + ); + + await expect(client.addToNamespaces(type, id, namespaces)).rejects.toThrow(); // test is simpler with error case + + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2); + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenNthCalledWith( + 1, + [privilege1], + namespaces + ); + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenNthCalledWith( + 2, + [privilege2], + undefined // default namespace + ); + }); +}); + +describe('#bulkCreate', () => { + const attributes = { some: 'attr' }; + const obj1 = Object.freeze({ type: 'foo', otherThing: 'sup', attributes }); + const obj2 = Object.freeze({ type: 'bar', otherThing: 'everyone', attributes }); + const options = Object.freeze({ namespace: 'some-ns' }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const objects = [obj1]; + await expectGeneralError(client.bulkCreate, { objects }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const objects = [obj1, obj2]; + await expectForbiddenError(client.bulkCreate, { objects, options }); + }); + + test(`returns result of baseClient.bulkCreate when authorized`, async () => { + const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + clientOpts.baseClient.bulkCreate.mockReturnValue(apiCallReturnValue as any); + + const objects = [obj1, obj2]; + const result = await expectSuccess(client.bulkCreate, { objects, options }); + expect(result).toEqual(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + const objects = [obj1, obj2]; + await expectPrivilegeCheck(client.bulkCreate, { objects, options }); + }); + + test(`filters namespaces that the user doesn't have access to`, async () => { + const objects = [obj1, obj2]; + await expectObjectsNamespaceFiltering(client.bulkCreate, { objects, options }); + }); +}); + +describe('#bulkGet', () => { + const obj1 = Object.freeze({ type: 'foo', id: 'foo-id' }); + const obj2 = Object.freeze({ type: 'bar', id: 'bar-id' }); + const options = Object.freeze({ namespace: 'some-ns' }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const objects = [obj1]; + await expectGeneralError(client.bulkGet, { objects }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const objects = [obj1, obj2]; + await expectForbiddenError(client.bulkGet, { objects, options }); + }); + + test(`returns result of baseClient.bulkGet when authorized`, async () => { + const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + clientOpts.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any); + + const objects = [obj1, obj2]; + const result = await expectSuccess(client.bulkGet, { objects, options }); + expect(result).toEqual(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + const objects = [obj1, obj2]; + await expectPrivilegeCheck(client.bulkGet, { objects, options }); + }); + + test(`filters namespaces that the user doesn't have access to`, async () => { + const objects = [obj1, obj2]; + await expectObjectsNamespaceFiltering(client.bulkGet, { objects, options }); + }); +}); + +describe('#bulkUpdate', () => { + const obj1 = Object.freeze({ type: 'foo', id: 'foo-id', attributes: { some: 'attr' } }); + const obj2 = Object.freeze({ type: 'bar', id: 'bar-id', attributes: { other: 'attr' } }); + const options = Object.freeze({ namespace: 'some-ns' }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const objects = [obj1]; + await expectGeneralError(client.bulkUpdate, { objects }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const objects = [obj1, obj2]; + await expectForbiddenError(client.bulkUpdate, { objects, options }); + }); + + test(`returns result of baseClient.bulkUpdate when authorized`, async () => { + const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + clientOpts.baseClient.bulkUpdate.mockReturnValue(apiCallReturnValue as any); + + const objects = [obj1, obj2]; + const result = await expectSuccess(client.bulkUpdate, { objects, options }); + expect(result).toEqual(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + const objects = [obj1, obj2]; + await expectPrivilegeCheck(client.bulkUpdate, { objects, options }); + }); + + test(`filters namespaces that the user doesn't have access to`, async () => { + const objects = [obj1, obj2]; + await expectObjectsNamespaceFiltering(client.bulkUpdate, { objects, options }); + }); +}); + +describe('#create', () => { + const type = 'foo'; + const attributes = { some_attr: 's' }; + const options = Object.freeze({ namespace: 'some-ns' }); + + test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { + await expectGeneralError(client.create, { type }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + await expectForbiddenError(client.create, { type, attributes, options }); + }); + + test(`returns result of baseClient.create when authorized`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.create.mockResolvedValue(apiCallReturnValue as any); + + const result = await expectSuccess(client.create, { type, attributes, options }); + expect(result).toBe(apiCallReturnValue); + }); - expect(client.errors).toBe(options.errors); + test(`checks privileges for user, actions, and namespace`, async () => { + await expectPrivilegeCheck(client.create, { type, attributes, options }); + }); + + test(`filters namespaces that the user doesn't have access to`, async () => { + await expectObjectNamespaceFiltering(client.create, { type, attributes, options }); }); }); -describe(`spaces disabled`, () => { - describe('#create', () => { - test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { - const type = 'foo'; - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( - new Error('An actual error would happen here') - ); - const client = new SecureSavedObjectsClientWrapper(options); - - await expect(client.create(type)).rejects.toThrowError(options.generalError); - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'create')], - undefined - ); - expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username, - privileges: { [options.actions.savedObject.get(type, 'create')]: false }, - }); - - const client = new SecureSavedObjectsClientWrapper(options); - - const attributes = { some_attr: 's' }; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.create(type, attributes, apiCallOptions)).rejects.toThrowError( - options.forbiddenError - ); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'create')], - apiCallOptions.namespace - ); - expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'create', - [type], - [options.actions.savedObject.get(type, 'create')], - { type, attributes, options: apiCallOptions } - ); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.create when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: true, - username, - privileges: { [options.actions.savedObject.get(type, 'create')]: true }, - }); - - const apiCallReturnValue = Symbol(); - options.baseClient.create.mockReturnValue(apiCallReturnValue as any); - - const client = new SecureSavedObjectsClientWrapper(options); - - const attributes = { some_attr: 's' }; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.create(type, attributes, apiCallOptions)).resolves.toBe( - apiCallReturnValue - ); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'create')], - apiCallOptions.namespace - ); - expect(options.baseClient.create).toHaveBeenCalledWith(type, attributes, apiCallOptions); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( - username, - 'create', - [type], - { type, attributes, options: apiCallOptions } - ); - }); - }); - - describe('#bulkCreate', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( - new Error('An actual error would happen here') - ); - const client = new SecureSavedObjectsClientWrapper(options); - - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect( - client.bulkCreate([{ type, attributes: {} }], apiCallOptions) - ).rejects.toThrowError(options.generalError); - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'bulk_create')], - apiCallOptions.namespace - ); - expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username, - privileges: { - [options.actions.savedObject.get(type1, 'bulk_create')]: false, - [options.actions.savedObject.get(type2, 'bulk_create')]: true, - }, - }); - - const client = new SecureSavedObjectsClientWrapper(options); - - const objects = [ - { type: type1, attributes: {} }, - { type: type2, attributes: {} }, - ]; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.bulkCreate(objects, apiCallOptions)).rejects.toThrowError( - options.forbiddenError - ); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [ - options.actions.savedObject.get(type1, 'bulk_create'), - options.actions.savedObject.get(type2, 'bulk_create'), - ], - apiCallOptions.namespace - ); - expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'bulk_create', - [type1, type2], - [options.actions.savedObject.get(type1, 'bulk_create')], - { objects, options: apiCallOptions } - ); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.bulkCreate when authorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: true, - username, - privileges: { - [options.actions.savedObject.get(type1, 'bulk_create')]: true, - [options.actions.savedObject.get(type2, 'bulk_create')]: true, - }, - }); - - const apiCallReturnValue = Symbol(); - options.baseClient.bulkCreate.mockReturnValue(apiCallReturnValue as any); - - const client = new SecureSavedObjectsClientWrapper(options); - - const objects = [ - { type: type1, otherThing: 'sup', attributes: {} }, - { type: type2, otherThing: 'everyone', attributes: {} }, - ]; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.bulkCreate(objects, apiCallOptions)).resolves.toBe(apiCallReturnValue); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [ - options.actions.savedObject.get(type1, 'bulk_create'), - options.actions.savedObject.get(type2, 'bulk_create'), - ], - apiCallOptions.namespace - ); - expect(options.baseClient.bulkCreate).toHaveBeenCalledWith(objects, apiCallOptions); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( - username, - 'bulk_create', - [type1, type2], - { objects, options: apiCallOptions } - ); - }); - }); - - describe('#delete', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( - new Error('An actual error would happen here') - ); - const client = new SecureSavedObjectsClientWrapper(options); - - await expect(client.delete(type, 'bar')).rejects.toThrowError(options.generalError); - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'delete')], - undefined - ); - expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const id = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username, - privileges: { - [options.actions.savedObject.get(type, 'delete')]: false, - }, - }); - - const client = new SecureSavedObjectsClientWrapper(options); - - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.delete(type, id, apiCallOptions)).rejects.toThrowError( - options.forbiddenError - ); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'delete')], - apiCallOptions.namespace - ); - expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'delete', - [type], - [options.actions.savedObject.get(type, 'delete')], - { type, id, options: apiCallOptions } - ); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of internalRepository.delete when authorized`, async () => { - const type = 'foo'; - const id = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: true, - username, - privileges: { [options.actions.savedObject.get(type, 'delete')]: true }, - }); - - const apiCallReturnValue = Symbol(); - options.baseClient.delete.mockReturnValue(apiCallReturnValue as any); - - const client = new SecureSavedObjectsClientWrapper(options); - - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.delete(type, id, apiCallOptions)).resolves.toBe(apiCallReturnValue); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'delete')], - apiCallOptions.namespace - ); - expect(options.baseClient.delete).toHaveBeenCalledWith(type, id, apiCallOptions); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( - username, - 'delete', - [type], - { type, id, options: apiCallOptions } - ); - }); - }); - - describe('#find', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( - new Error('An actual error would happen here') - ); - const client = new SecureSavedObjectsClientWrapper(options); - - await expect(client.find({ type })).rejects.toThrowError(options.generalError); - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'find')], - undefined - ); - expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when type's singular and unauthorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username, - privileges: { [options.actions.savedObject.get(type, 'find')]: false }, - }); - - const client = new SecureSavedObjectsClientWrapper(options); - - const apiCallOptions = Object.freeze({ type, namespace: 'some-ns' }); - await expect(client.find(apiCallOptions)).rejects.toThrowError(options.forbiddenError); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'find')], - apiCallOptions.namespace - ); - expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'find', - [type], - [options.actions.savedObject.get(type, 'find')], - { options: apiCallOptions } - ); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username, - privileges: { - [options.actions.savedObject.get(type1, 'find')]: false, - [options.actions.savedObject.get(type2, 'find')]: true, - }, - }); - - const client = new SecureSavedObjectsClientWrapper(options); - - const apiCallOptions = Object.freeze({ type: [type1, type2], namespace: 'some-ns' }); - await expect(client.find(apiCallOptions)).rejects.toThrowError(options.forbiddenError); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [ - options.actions.savedObject.get(type1, 'find'), - options.actions.savedObject.get(type2, 'find'), - ], - apiCallOptions.namespace - ); - expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'find', - [type1, type2], - [options.actions.savedObject.get(type1, 'find')], - { options: apiCallOptions } - ); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.find when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: true, - username, - privileges: { [options.actions.savedObject.get(type, 'find')]: true }, - }); - - const apiCallReturnValue = Symbol(); - options.baseClient.find.mockReturnValue(apiCallReturnValue as any); - - const client = new SecureSavedObjectsClientWrapper(options); - - const apiCallOptions = Object.freeze({ type, namespace: 'some-ns' }); - await expect(client.find(apiCallOptions)).resolves.toBe(apiCallReturnValue); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'find')], - apiCallOptions.namespace - ); - expect(options.baseClient.find).toHaveBeenCalledWith(apiCallOptions); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( - username, - 'find', - [type], - { options: apiCallOptions } - ); - }); - }); - - describe('#bulkGet', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( - new Error('An actual error would happen here') - ); - const client = new SecureSavedObjectsClientWrapper(options); - - await expect(client.bulkGet([{ id: 'bar', type }])).rejects.toThrowError( - options.generalError - ); - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'bulk_get')], - undefined - ); - expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username, - privileges: { - [options.actions.savedObject.get(type1, 'bulk_get')]: false, - [options.actions.savedObject.get(type2, 'bulk_get')]: true, - }, - }); - - const client = new SecureSavedObjectsClientWrapper(options); - - const objects = [ - { type: type1, id: `bar-${type1}` }, - { type: type2, id: `bar-${type2}` }, - ]; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.bulkGet(objects, apiCallOptions)).rejects.toThrowError( - options.forbiddenError - ); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [ - options.actions.savedObject.get(type1, 'bulk_get'), - options.actions.savedObject.get(type2, 'bulk_get'), - ], - apiCallOptions.namespace - ); - expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'bulk_get', - [type1, type2], - [options.actions.savedObject.get(type1, 'bulk_get')], - { objects, options: apiCallOptions } - ); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.bulkGet when authorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: true, - username, - privileges: { - [options.actions.savedObject.get(type1, 'bulk_get')]: true, - [options.actions.savedObject.get(type2, 'bulk_get')]: true, - }, - }); - - const apiCallReturnValue = Symbol(); - options.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any); - - const client = new SecureSavedObjectsClientWrapper(options); - - const objects = [ - { type: type1, id: `id-${type1}` }, - { type: type2, id: `id-${type2}` }, - ]; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.bulkGet(objects, apiCallOptions)).resolves.toBe(apiCallReturnValue); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [ - options.actions.savedObject.get(type1, 'bulk_get'), - options.actions.savedObject.get(type2, 'bulk_get'), - ], - apiCallOptions.namespace - ); - expect(options.baseClient.bulkGet).toHaveBeenCalledWith(objects, apiCallOptions); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( - username, - 'bulk_get', - [type1, type2], - { objects, options: apiCallOptions } - ); - }); - }); - - describe('#get', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( - new Error('An actual error would happen here') - ); - const client = new SecureSavedObjectsClientWrapper(options); - - await expect(client.get(type, 'bar')).rejects.toThrowError(options.generalError); - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'get')], - undefined - ); - expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const id = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username, - privileges: { - [options.actions.savedObject.get(type, 'get')]: false, - }, - }); - - const client = new SecureSavedObjectsClientWrapper(options); - - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.get(type, id, apiCallOptions)).rejects.toThrowError( - options.forbiddenError - ); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'get')], - apiCallOptions.namespace - ); - expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'get', - [type], - [options.actions.savedObject.get(type, 'get')], - { type, id, options: apiCallOptions } - ); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.get when authorized`, async () => { - const type = 'foo'; - const id = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: true, - username, - privileges: { [options.actions.savedObject.get(type, 'get')]: true }, - }); - - const apiCallReturnValue = Symbol(); - options.baseClient.get.mockReturnValue(apiCallReturnValue as any); - - const client = new SecureSavedObjectsClientWrapper(options); - - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.get(type, id, apiCallOptions)).resolves.toBe(apiCallReturnValue); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'get')], - apiCallOptions.namespace - ); - expect(options.baseClient.get).toHaveBeenCalledWith(type, id, apiCallOptions); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( - username, - 'get', - [type], - { type, id, options: apiCallOptions } - ); - }); - }); - - describe('#update', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( - new Error('An actual error would happen here') - ); - const client = new SecureSavedObjectsClientWrapper(options); - - await expect(client.update(type, 'bar', {})).rejects.toThrowError(options.generalError); - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'update')], - undefined - ); - expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const id = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username, - privileges: { - [options.actions.savedObject.get(type, 'update')]: false, - }, - }); - - const client = new SecureSavedObjectsClientWrapper(options); - - const attributes = { some: 'attr' }; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.update(type, id, attributes, apiCallOptions)).rejects.toThrowError( - options.forbiddenError - ); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'update')], - apiCallOptions.namespace - ); - expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'update', - [type], - [options.actions.savedObject.get(type, 'update')], - { type, id, attributes, options: apiCallOptions } - ); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.update when authorized`, async () => { - const type = 'foo'; - const id = 'bar'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: true, - username, - privileges: { [options.actions.savedObject.get(type, 'update')]: true }, - }); - - const apiCallReturnValue = Symbol(); - options.baseClient.update.mockReturnValue(apiCallReturnValue as any); - - const client = new SecureSavedObjectsClientWrapper(options); - - const attributes = { some: 'attr' }; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.update(type, id, attributes, apiCallOptions)).resolves.toBe( - apiCallReturnValue - ); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'update')], - apiCallOptions.namespace - ); - expect(options.baseClient.update).toHaveBeenCalledWith(type, id, attributes, apiCallOptions); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( - username, - 'update', - [type], - { type, id, attributes, options: apiCallOptions } - ); - }); - }); - - describe('#bulkUpdate', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( - new Error('An actual error would happen here') - ); - const client = new SecureSavedObjectsClientWrapper(options); - - await expect(client.bulkUpdate([{ id: 'bar', type, attributes: {} }])).rejects.toThrowError( - options.generalError - ); - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'bulk_update')], - undefined - ); - expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: false, - username, - privileges: { - [options.actions.savedObject.get(type, 'bulk_update')]: false, - }, - }); - - const client = new SecureSavedObjectsClientWrapper(options); - - const objects = [{ type, id: `bar-${type}`, attributes: {} }]; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.bulkUpdate(objects, apiCallOptions)).rejects.toThrowError( - options.forbiddenError - ); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'bulk_update')], - apiCallOptions.namespace - ); - expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'bulk_update', - [type], - [options.actions.savedObject.get(type, 'bulk_update')], - { objects, options: apiCallOptions } - ); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of baseClient.bulkUpdate when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const options = createSecureSavedObjectsClientWrapperOptions(); - options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ - hasAllRequested: true, - username, - privileges: { - [options.actions.savedObject.get(type, 'bulk_update')]: true, - }, - }); - - const apiCallReturnValue = Symbol(); - options.baseClient.bulkUpdate.mockReturnValue(apiCallReturnValue as any); - - const client = new SecureSavedObjectsClientWrapper(options); - - const objects = [{ type, id: `id-${type}`, attributes: {} }]; - const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); - await expect(client.bulkUpdate(objects, apiCallOptions)).resolves.toBe(apiCallReturnValue); - - expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( - [options.actions.savedObject.get(type, 'bulk_update')], - apiCallOptions.namespace - ); - expect(options.baseClient.bulkUpdate).toHaveBeenCalledWith(objects, apiCallOptions); - expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( - username, - 'bulk_update', - [type], - { objects, options: apiCallOptions } - ); - }); +describe('#delete', () => { + const type = 'foo'; + const id = `${type}-id`; + const options = Object.freeze({ namespace: 'some-ns' }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + await expectGeneralError(client.delete, { type, id }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + await expectForbiddenError(client.delete, { type, id, options }); + }); + + test(`returns result of internalRepository.delete when authorized`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.delete.mockReturnValue(apiCallReturnValue as any); + + const result = await expectSuccess(client.delete, { type, id, options }); + expect(result).toBe(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + await expectPrivilegeCheck(client.delete, { type, id, options }); + }); +}); + +describe('#find', () => { + const type1 = 'foo'; + const type2 = 'bar'; + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + await expectGeneralError(client.find, { type: type1 }); + }); + + test(`throws decorated ForbiddenError when type's singular and unauthorized`, async () => { + const options = Object.freeze({ type: type1, namespace: 'some-ns' }); + await expectForbiddenError(client.find, { options }); + }); + + test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => { + const options = Object.freeze({ type: [type1, type2], namespace: 'some-ns' }); + await expectForbiddenError(client.find, { options }); + }); + + test(`returns result of baseClient.find when authorized`, async () => { + const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any); + + const options = Object.freeze({ type: type1, namespace: 'some-ns' }); + const result = await expectSuccess(client.find, { options }); + expect(result).toEqual(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + const options = Object.freeze({ type: [type1, type2], namespace: 'some-ns' }); + await expectPrivilegeCheck(client.find, { options }); + }); + + test(`filters namespaces that the user doesn't have access to`, async () => { + const options = Object.freeze({ type: [type1, type2], namespace: 'some-ns' }); + await expectObjectsNamespaceFiltering(client.find, { options }); + }); +}); + +describe('#get', () => { + const type = 'foo'; + const id = `${type}-id`; + const options = Object.freeze({ namespace: 'some-ns' }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + await expectGeneralError(client.get, { type, id }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + await expectForbiddenError(client.get, { type, id, options }); + }); + + test(`returns result of baseClient.get when authorized`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.get.mockReturnValue(apiCallReturnValue as any); + + const result = await expectSuccess(client.get, { type, id, options }); + expect(result).toBe(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + await expectPrivilegeCheck(client.get, { type, id, options }); + }); + + test(`filters namespaces that the user doesn't have access to`, async () => { + await expectObjectNamespaceFiltering(client.get, { type, id, options }); + }); +}); + +describe('#deleteFromNamespaces', () => { + const type = 'foo'; + const id = `${type}-id`; + const namespace1 = 'foo-namespace'; + const namespace2 = 'bar-namespace'; + const namespaces = [namespace1, namespace2]; + const privilege = `mock-saved_object:${type}/delete`; + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + await expectGeneralError(client.deleteFromNamespaces, { type, id, namespaces }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure + ); + + await expect(client.deleteFromNamespaces(type, id, namespaces)).rejects.toThrowError( + clientOpts.forbiddenError + ); + + expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + USERNAME, + 'deleteFromNamespaces', // action for privilege check is 'delete', but auditAction is 'deleteFromNamespaces' + [type], + namespaces.sort(), + [{ privilege, spaceId: namespace1 }], + { type, id, namespaces, options: {} } + ); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.deleteFromNamespaces when authorized`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.deleteFromNamespaces.mockReturnValue(apiCallReturnValue as any); + + const result = await client.deleteFromNamespaces(type, id, namespaces); + expect(result).toBe(apiCallReturnValue); + + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + USERNAME, + 'deleteFromNamespaces', // action for privilege check is 'delete', but auditAction is 'deleteFromNamespaces' + [type], + namespaces.sort(), + { type, id, namespaces, options: {} } + ); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure + ); + + await expect(client.deleteFromNamespaces(type, id, namespaces)).rejects.toThrow(); // test is simpler with error case + + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(1); + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [privilege], + namespaces + ); + }); +}); + +describe('#update', () => { + const type = 'foo'; + const id = `${type}-id`; + const attributes = { some: 'attr' }; + const options = Object.freeze({ namespace: 'some-ns' }); + + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + await expectGeneralError(client.update, { type, id, attributes }); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + await expectForbiddenError(client.update, { type, id, attributes, options }); + }); + + test(`returns result of baseClient.update when authorized`, async () => { + const apiCallReturnValue = Symbol(); + clientOpts.baseClient.update.mockReturnValue(apiCallReturnValue as any); + + const result = await expectSuccess(client.update, { type, id, attributes, options }); + expect(result).toBe(apiCallReturnValue); + }); + + test(`checks privileges for user, actions, and namespace`, async () => { + await expectPrivilegeCheck(client.update, { type, id, attributes, options }); + }); + + test(`filters namespaces that the user doesn't have access to`, async () => { + await expectObjectNamespaceFiltering(client.update, { type, id, attributes, options }); + }); +}); + +describe('other', () => { + test(`assigns errors from constructor to .errors`, () => { + expect(client.errors).toBe(clientOpts.errors); }); }); diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index 2209e7fb66fcb4..29503d475be73d 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -13,9 +13,13 @@ import { SavedObjectsCreateOptions, SavedObjectsFindOptions, SavedObjectsUpdateOptions, + SavedObjectsAddToNamespacesOptions, + SavedObjectsDeleteFromNamespacesOptions, } from '../../../../../src/core/server'; import { SecurityAuditLogger } from '../audit'; import { Actions, CheckSavedObjectsPrivileges } from '../authorization'; +import { CheckPrivilegesResponse } from '../authorization/check_privileges'; +import { SpacesService } from '../plugin'; interface SecureSavedObjectsClientWrapperOptions { actions: Actions; @@ -23,6 +27,19 @@ interface SecureSavedObjectsClientWrapperOptions { baseClient: SavedObjectsClientContract; errors: SavedObjectsClientContract['errors']; checkSavedObjectsPrivilegesAsCurrentUser: CheckSavedObjectsPrivileges; + getSpacesService(): SpacesService | undefined; +} + +interface SavedObjectNamespaces { + namespaces?: string[]; +} + +interface SavedObjectsNamespaces { + saved_objects: SavedObjectNamespaces[]; +} + +function uniq(arr: T[]): T[] { + return Array.from(new Set(arr)); } export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContract { @@ -30,19 +47,23 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra private readonly auditLogger: PublicMethodsOf; private readonly baseClient: SavedObjectsClientContract; private readonly checkSavedObjectsPrivilegesAsCurrentUser: CheckSavedObjectsPrivileges; + private getSpacesService: () => SpacesService | undefined; public readonly errors: SavedObjectsClientContract['errors']; + constructor({ actions, auditLogger, baseClient, checkSavedObjectsPrivilegesAsCurrentUser, errors, + getSpacesService, }: SecureSavedObjectsClientWrapperOptions) { this.errors = errors; this.actions = actions; this.auditLogger = auditLogger; this.baseClient = baseClient; this.checkSavedObjectsPrivilegesAsCurrentUser = checkSavedObjectsPrivilegesAsCurrentUser; + this.getSpacesService = getSpacesService; } public async create( @@ -52,7 +73,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ) { await this.ensureAuthorized(type, 'create', options.namespace, { type, attributes, options }); - return await this.baseClient.create(type, attributes, options); + const savedObject = await this.baseClient.create(type, attributes, options); + return await this.redactSavedObjectNamespaces(savedObject); } public async bulkCreate( @@ -66,7 +88,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra { objects, options } ); - return await this.baseClient.bulkCreate(objects, options); + const response = await this.baseClient.bulkCreate(objects, options); + return await this.redactSavedObjectsNamespaces(response); } public async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) { @@ -78,7 +101,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra public async find(options: SavedObjectsFindOptions) { await this.ensureAuthorized(options.type, 'find', options.namespace, { options }); - return this.baseClient.find(options); + const response = await this.baseClient.find(options); + return await this.redactSavedObjectsNamespaces(response); } public async bulkGet( @@ -90,13 +114,15 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra options, }); - return await this.baseClient.bulkGet(objects, options); + const response = await this.baseClient.bulkGet(objects, options); + return await this.redactSavedObjectsNamespaces(response); } public async get(type: string, id: string, options: SavedObjectsBaseOptions = {}) { await this.ensureAuthorized(type, 'get', options.namespace, { type, id, options }); - return await this.baseClient.get(type, id, options); + const savedObject = await this.baseClient.get(type, id, options); + return await this.redactSavedObjectNamespaces(savedObject); } public async update( @@ -105,14 +131,44 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra attributes: Partial, options: SavedObjectsUpdateOptions = {} ) { - await this.ensureAuthorized(type, 'update', options.namespace, { - type, - id, - attributes, - options, - }); + const args = { type, id, attributes, options }; + await this.ensureAuthorized(type, 'update', options.namespace, args); - return await this.baseClient.update(type, id, attributes, options); + const savedObject = await this.baseClient.update(type, id, attributes, options); + return await this.redactSavedObjectNamespaces(savedObject); + } + + public async addToNamespaces( + type: string, + id: string, + namespaces: string[], + options: SavedObjectsAddToNamespacesOptions = {} + ) { + const args = { type, id, namespaces, options }; + const { namespace } = options; + // To share an object, the user must have the "create" permission in each of the destination namespaces. + await this.ensureAuthorized(type, 'create', namespaces, args, 'addToNamespacesCreate'); + + // To share an object, the user must also have the "update" permission in one or more of the source namespaces. Because the + // `addToNamespaces` operation is scoped to the current namespace, we can just check if the user has the "update" permission in the + // current namespace. If the user has permission, but the saved object doesn't exist in this namespace, the base client operation will + // result in a 404 error. + await this.ensureAuthorized(type, 'update', namespace, args, 'addToNamespacesUpdate'); + + return await this.baseClient.addToNamespaces(type, id, namespaces, options); + } + + public async deleteFromNamespaces( + type: string, + id: string, + namespaces: string[], + options: SavedObjectsDeleteFromNamespacesOptions = {} + ) { + const args = { type, id, namespaces, options }; + // To un-share an object, the user must have the "delete" permission in each of the target namespaces. + await this.ensureAuthorized(type, 'delete', namespaces, args, 'deleteFromNamespaces'); + + return await this.baseClient.deleteFromNamespaces(type, id, namespaces, options); } public async bulkUpdate( @@ -126,12 +182,16 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra { objects, options } ); - return await this.baseClient.bulkUpdate(objects, options); + const response = await this.baseClient.bulkUpdate(objects, options); + return await this.redactSavedObjectsNamespaces(response); } - private async checkPrivileges(actions: string | string[], namespace?: string) { + private async checkPrivileges( + actions: string | string[], + namespaceOrNamespaces?: string | string[] + ) { try { - return await this.checkSavedObjectsPrivilegesAsCurrentUser(actions, namespace); + return await this.checkSavedObjectsPrivilegesAsCurrentUser(actions, namespaceOrNamespaces); } catch (error) { throw this.errors.decorateGeneralError(error, error.body && error.body.reason); } @@ -140,43 +200,133 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra private async ensureAuthorized( typeOrTypes: string | string[], action: string, - namespace?: string, - args?: Record + namespaceOrNamespaces?: string | string[], + args?: Record, + auditAction: string = action, + requiresAll = true ) { const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; const actionsToTypesMap = new Map( types.map(type => [this.actions.savedObject.get(type, action), type]) ); const actions = Array.from(actionsToTypesMap.keys()); - const { hasAllRequested, username, privileges } = await this.checkPrivileges( - actions, - namespace - ); + const result = await this.checkPrivileges(actions, namespaceOrNamespaces); + + const { hasAllRequested, username, privileges } = result; + const spaceIds = uniq( + privileges.map(({ resource }) => resource).filter(x => x !== undefined) + ).sort() as string[]; - if (hasAllRequested) { - this.auditLogger.savedObjectsAuthorizationSuccess(username, action, types, args); + const isAuthorized = + (requiresAll && hasAllRequested) || + (!requiresAll && privileges.some(({ authorized }) => authorized)); + if (isAuthorized) { + this.auditLogger.savedObjectsAuthorizationSuccess( + username, + auditAction, + types, + spaceIds, + args + ); } else { const missingPrivileges = this.getMissingPrivileges(privileges); this.auditLogger.savedObjectsAuthorizationFailure( username, - action, + auditAction, types, + spaceIds, missingPrivileges, args ); - const msg = `Unable to ${action} ${missingPrivileges - .map(privilege => actionsToTypesMap.get(privilege)) - .sort() - .join(',')}`; + const targetTypes = uniq( + missingPrivileges.map(({ privilege }) => actionsToTypesMap.get(privilege)).sort() + ).join(','); + const msg = `Unable to ${action} ${targetTypes}`; throw this.errors.decorateForbiddenError(new Error(msg)); } } - private getMissingPrivileges(privileges: Record) { - return Object.keys(privileges).filter(privilege => !privileges[privilege]); + private getMissingPrivileges(privileges: CheckPrivilegesResponse['privileges']) { + return privileges + .filter(({ authorized }) => !authorized) + .map(({ resource, privilege }) => ({ spaceId: resource, privilege })); } private getUniqueObjectTypes(objects: Array<{ type: string }>) { - return [...new Set(objects.map(o => o.type))]; + return uniq(objects.map(o => o.type)); + } + + private async getNamespacesPrivilegeMap(namespaces: string[]) { + const action = this.actions.login; + const checkPrivilegesResult = await this.checkPrivileges(action, namespaces); + // check if the user can log into each namespace + const map = checkPrivilegesResult.privileges.reduce( + (acc: Record, { resource, authorized }) => { + // there should never be a case where more than one privilege is returned for a given space + // if there is, fail-safe (authorized + unauthorized = unauthorized) + if (resource && (!authorized || !acc.hasOwnProperty(resource))) { + acc[resource] = authorized; + } + return acc; + }, + {} + ); + return map; + } + + private redactAndSortNamespaces(spaceIds: string[], privilegeMap: Record) { + const comparator = (a: string, b: string) => { + const _a = a.toLowerCase(); + const _b = b.toLowerCase(); + if (_a === '?') { + return 1; + } else if (_a < _b) { + return -1; + } else if (_a > _b) { + return 1; + } + return 0; + }; + return spaceIds.map(spaceId => (privilegeMap[spaceId] ? spaceId : '?')).sort(comparator); + } + + private async redactSavedObjectNamespaces( + savedObject: T + ): Promise { + if (this.getSpacesService() === undefined || savedObject.namespaces == null) { + return savedObject; + } + + const privilegeMap = await this.getNamespacesPrivilegeMap(savedObject.namespaces); + + return { + ...savedObject, + namespaces: this.redactAndSortNamespaces(savedObject.namespaces, privilegeMap), + }; + } + + private async redactSavedObjectsNamespaces( + response: T + ): Promise { + if (this.getSpacesService() === undefined) { + return response; + } + const { saved_objects: savedObjects } = response; + const namespaces = uniq(savedObjects.flatMap(savedObject => savedObject.namespaces || [])); + if (namespaces.length === 0) { + return response; + } + + const privilegeMap = await this.getNamespacesPrivilegeMap(namespaces); + + return { + ...response, + saved_objects: savedObjects.map(savedObject => ({ + ...savedObject, + namespaces: + savedObject.namespaces && + this.redactAndSortNamespaces(savedObject.namespaces, privilegeMap), + })), + }; } } diff --git a/x-pack/plugins/spaces/public/management/edit_space/delete_spaces_button.tsx b/x-pack/plugins/spaces/public/management/edit_space/delete_spaces_button.tsx index 28e45bc8cfd2a8..ea63905e27b26e 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/delete_spaces_button.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/delete_spaces_button.tsx @@ -99,10 +99,14 @@ export class DeleteSpacesButton extends Component { public deleteSpaces = async () => { const { spacesManager, space } = this.props; + this.setState({ + showConfirmDeleteModal: false, + }); + try { await spacesManager.deleteSpace(space); } catch (error) { - const { message: errorMessage = '' } = error.data || {}; + const { message: errorMessage = '' } = error.data || error.body || {}; this.props.notifications.toasts.addDanger( i18n.translate('xpack.spaces.management.deleteSpacesButton.deleteSpaceErrorTitle', { @@ -110,12 +114,9 @@ export class DeleteSpacesButton extends Component { values: { errorMessage }, }) ); + return; } - this.setState({ - showConfirmDeleteModal: false, - }); - const message = i18n.translate( 'xpack.spaces.management.deleteSpacesButton.spaceSuccessfullyDeletedNotificationMessage', { diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx index ff4be842078324..df5e6a2ca34af5 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx @@ -176,10 +176,14 @@ export class SpacesGridPage extends Component { return; } + this.setState({ + showConfirmDeleteModal: false, + }); + try { await spacesManager.deleteSpace(space); } catch (error) { - const { message: errorMessage = '' } = error.data || {}; + const { message: errorMessage = '' } = error.data || error.body || {}; this.props.notifications.toasts.addDanger( i18n.translate('xpack.spaces.management.spacesGridPage.errorDeletingSpaceErrorMessage', { @@ -189,12 +193,9 @@ export class SpacesGridPage extends Component { }, }) ); + return; } - this.setState({ - showConfirmDeleteModal: false, - }); - this.loadGrid(); const message = i18n.translate( diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts index 59e157c3fc2dba..72faab0d2c892b 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts @@ -188,11 +188,13 @@ describe('copySavedObjectsToSpaces', () => { }, ], "savedObjectsClient": Object { + "addToNamespaces": [MockFunction], "bulkCreate": [MockFunction], "bulkGet": [MockFunction], "bulkUpdate": [MockFunction], "create": [MockFunction], "delete": [MockFunction], + "deleteFromNamespaces": [MockFunction], "errors": [Function], "find": [MockFunction], "get": [MockFunction], @@ -252,11 +254,13 @@ describe('copySavedObjectsToSpaces', () => { "readable": false, }, "savedObjectsClient": Object { + "addToNamespaces": [MockFunction], "bulkCreate": [MockFunction], "bulkGet": [MockFunction], "bulkUpdate": [MockFunction], "create": [MockFunction], "delete": [MockFunction], + "deleteFromNamespaces": [MockFunction], "errors": [Function], "find": [MockFunction], "get": [MockFunction], @@ -315,11 +319,13 @@ describe('copySavedObjectsToSpaces', () => { "readable": false, }, "savedObjectsClient": Object { + "addToNamespaces": [MockFunction], "bulkCreate": [MockFunction], "bulkGet": [MockFunction], "bulkUpdate": [MockFunction], "create": [MockFunction], "delete": [MockFunction], + "deleteFromNamespaces": [MockFunction], "errors": [Function], "find": [MockFunction], "get": [MockFunction], diff --git a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts index 7809f1f8be66f5..aa1d5e9a478326 100644 --- a/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts +++ b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts @@ -204,11 +204,13 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { }, ], "savedObjectsClient": Object { + "addToNamespaces": [MockFunction], "bulkCreate": [MockFunction], "bulkGet": [MockFunction], "bulkUpdate": [MockFunction], "create": [MockFunction], "delete": [MockFunction], + "deleteFromNamespaces": [MockFunction], "errors": [Function], "find": [MockFunction], "get": [MockFunction], @@ -275,11 +277,13 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { }, ], "savedObjectsClient": Object { + "addToNamespaces": [MockFunction], "bulkCreate": [MockFunction], "bulkGet": [MockFunction], "bulkUpdate": [MockFunction], "create": [MockFunction], "delete": [MockFunction], + "deleteFromNamespaces": [MockFunction], "errors": [Function], "find": [MockFunction], "get": [MockFunction], @@ -345,11 +349,13 @@ describe('resolveCopySavedObjectsToSpacesConflicts', () => { }, ], "savedObjectsClient": Object { + "addToNamespaces": [MockFunction], "bulkCreate": [MockFunction], "bulkGet": [MockFunction], "bulkUpdate": [MockFunction], "create": [MockFunction], "delete": [MockFunction], + "deleteFromNamespaces": [MockFunction], "errors": [Function], "find": [MockFunction], "get": [MockFunction], diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts index 74e75fb8f12c79..c83830f6feace5 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts @@ -250,14 +250,10 @@ describe('#getAll', () => { mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); mockCheckPrivilegesAtSpaces.mockReturnValue({ username, - spacePrivileges: { - [savedObjects[0].id]: { - [privilege]: false, - }, - [savedObjects[1].id]: { - [privilege]: false, - }, - }, + privileges: [ + { resource: savedObjects[0].id, privilege, authorized: false }, + { resource: savedObjects[1].id, privilege, authorized: false }, + ], }); const maxSpaces = 1234; const mockConfig = createMockConfig({ @@ -314,14 +310,10 @@ describe('#getAll', () => { mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); mockCheckPrivilegesAtSpaces.mockReturnValue({ username, - spacePrivileges: { - [savedObjects[0].id]: { - [privilege]: true, - }, - [savedObjects[1].id]: { - [privilege]: false, - }, - }, + privileges: [ + { resource: savedObjects[0].id, privilege, authorized: true }, + { resource: savedObjects[1].id, privilege, authorized: false }, + ], }); const mockInternalRepository = { find: jest.fn().mockReturnValue({ diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts index 22c34c03368e36..0c066fb76994f7 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts @@ -74,16 +74,14 @@ export class SpacesClient { const privilege = privilegeFactory(this.authorization!); - const { username, spacePrivileges } = await checkPrivileges.atSpaces(spaceIds, privilege); + const { username, privileges } = await checkPrivileges.atSpaces(spaceIds, privilege); - const authorized = Object.keys(spacePrivileges).filter(spaceId => { - return spacePrivileges[spaceId][privilege]; - }); + const authorized = privileges.filter(x => x.authorized).map(x => x.resource); this.debugLogger( `SpacesClient.getAll(), authorized for ${ authorized.length - } spaces, derived from ES privilege check: ${JSON.stringify(spacePrivileges)}` + } spaces, derived from ES privilege check: ${JSON.stringify(privileges)}` ); if (authorized.length === 0) { @@ -94,7 +92,7 @@ export class SpacesClient { throw Boom.forbidden(); } - this.auditLogger.spacesAuthorizationSuccess(username, 'getAll', authorized); + this.auditLogger.spacesAuthorizationSuccess(username, 'getAll', authorized as string[]); const filteredSpaces: Space[] = spaces.filter((space: any) => authorized.includes(space.id)); this.debugLogger( `SpacesClient.getAll(), using RBAC. returning spaces: ${filteredSpaces @@ -211,9 +209,9 @@ export class SpacesClient { throw Boom.badRequest('This Space cannot be deleted because it is reserved.'); } - await repository.delete('space', id); - await repository.deleteByNamespace(id); + + await repository.delete('space', id); } private useRbac(): boolean { diff --git a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts index f2ba8785f5a3f3..511e9676940d2e 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts @@ -11,7 +11,13 @@ import { mockRouteContext, mockRouteContextWithInvalidLicense, } from '../__fixtures__'; -import { CoreSetup, IRouter, kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; +import { + CoreSetup, + IRouter, + kibanaResponseFactory, + RouteValidatorConfig, + SavedObjectsErrorHelpers, +} from 'src/core/server'; import { loggingServiceMock, httpServiceMock, @@ -75,6 +81,7 @@ describe('Spaces Public API', () => { return { routeValidation: routeDefinition.validate as RouteValidatorConfig<{}, {}, {}>, routeHandler, + savedObjectsRepositoryMock, }; }; @@ -143,6 +150,27 @@ describe('Spaces Public API', () => { expect(status).toEqual(404); }); + it(`returns http/400 when scripts cannot be executed in Elasticsearch`, async () => { + const { routeHandler, savedObjectsRepositoryMock } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + params: { + id: 'a-space', + }, + method: 'delete', + }); + // @ts-ignore + savedObjectsRepositoryMock.deleteByNamespace.mockRejectedValue( + SavedObjectsErrorHelpers.decorateEsCannotExecuteScriptError(new Error()) + ); + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + const { status, payload } = response; + + expect(status).toEqual(400); + expect(payload.message).toEqual('Cannot execute script in Elasticsearch query'); + }); + it(`DELETE spaces/{id}' cannot delete reserved spaces`, async () => { const { routeHandler } = await setup(); diff --git a/x-pack/plugins/spaces/server/routes/api/external/delete.ts b/x-pack/plugins/spaces/server/routes/api/external/delete.ts index 4b7e6b00182acf..150f1d198cdf69 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/delete.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/delete.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import Boom from 'boom'; import { schema } from '@kbn/config-schema'; import { SavedObjectsErrorHelpers } from '../../../../../../../src/core/server'; import { wrapError } from '../../../lib/errors'; @@ -12,7 +13,7 @@ import { ExternalRouteDeps } from '.'; import { createLicensedRouteHandler } from '../../lib'; export function initDeleteSpacesApi(deps: ExternalRouteDeps) { - const { externalRouter, spacesService } = deps; + const { externalRouter, log, spacesService } = deps; externalRouter.delete( { @@ -33,6 +34,13 @@ export function initDeleteSpacesApi(deps: ExternalRouteDeps) { } catch (error) { if (SavedObjectsErrorHelpers.isNotFoundError(error)) { return response.notFound(); + } else if (SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)) { + log.error( + `Failed to delete space '${id}', cannot execute script in Elasticsearch query: ${error.message}` + ); + return response.customError( + wrapError(Boom.badRequest('Cannot execute script in Elasticsearch query')) + ); } return response.customError(wrapError(error)); } diff --git a/x-pack/plugins/spaces/server/routes/api/external/index.ts b/x-pack/plugins/spaces/server/routes/api/external/index.ts index 1bdb7ceb8a3f7e..079f690bfe5463 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/index.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/index.ts @@ -12,6 +12,8 @@ import { initPostSpacesApi } from './post'; import { initPutSpacesApi } from './put'; import { SpacesServiceSetup } from '../../../spaces_service/spaces_service'; import { initCopyToSpacesApi } from './copy_to_space'; +import { initShareAddSpacesApi } from './share_add_spaces'; +import { initShareRemoveSpacesApi } from './share_remove_spaces'; export interface ExternalRouteDeps { externalRouter: IRouter; @@ -28,4 +30,6 @@ export function initExternalSpacesApi(deps: ExternalRouteDeps) { initPostSpacesApi(deps); initPutSpacesApi(deps); initCopyToSpacesApi(deps); + initShareAddSpacesApi(deps); + initShareRemoveSpacesApi(deps); } diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_add_spaces.ts b/x-pack/plugins/spaces/server/routes/api/external/share_add_spaces.ts new file mode 100644 index 00000000000000..f40cc5cc505722 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/share_add_spaces.ts @@ -0,0 +1,62 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { wrapError } from '../../../lib/errors'; +import { ExternalRouteDeps } from '.'; +import { SPACE_ID_REGEX } from '../../../lib/space_schema'; +import { createLicensedRouteHandler } from '../../lib'; + +const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); +export function initShareAddSpacesApi(deps: ExternalRouteDeps) { + const { externalRouter, getStartServices } = deps; + + externalRouter.post( + { + path: '/api/spaces/_share_saved_object_add', + validate: { + body: schema.object({ + spaces: schema.arrayOf( + schema.string({ + validate: value => { + if (!SPACE_ID_REGEX.test(value)) { + return `lower case, a-z, 0-9, "_", and "-" are allowed`; + } + }, + }), + { + validate: spaceIds => { + if (!spaceIds.length) { + return 'must specify one or more space ids'; + } else if (uniq(spaceIds).length !== spaceIds.length) { + return 'duplicate space ids are not allowed'; + } + }, + } + ), + object: schema.object({ + type: schema.string(), + id: schema.string(), + }), + }), + }, + }, + createLicensedRouteHandler(async (_context, request, response) => { + const [startServices] = await getStartServices(); + const scopedClient = startServices.savedObjects.getScopedClient(request); + + const spaces = request.body.spaces; + const { type, id } = request.body.object; + + try { + await scopedClient.addToNamespaces(type, id, spaces); + } catch (error) { + return response.customError(wrapError(error)); + } + return response.noContent(); + }) + ); +} diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_remove_spaces.ts b/x-pack/plugins/spaces/server/routes/api/external/share_remove_spaces.ts new file mode 100644 index 00000000000000..5f58a5dfd5e5f1 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/share_remove_spaces.ts @@ -0,0 +1,62 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { wrapError } from '../../../lib/errors'; +import { ExternalRouteDeps } from '.'; +import { SPACE_ID_REGEX } from '../../../lib/space_schema'; +import { createLicensedRouteHandler } from '../../lib'; + +const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); +export function initShareRemoveSpacesApi(deps: ExternalRouteDeps) { + const { externalRouter, getStartServices } = deps; + + externalRouter.post( + { + path: '/api/spaces/_share_saved_object_remove', + validate: { + body: schema.object({ + spaces: schema.arrayOf( + schema.string({ + validate: value => { + if (!SPACE_ID_REGEX.test(value)) { + return `lower case, a-z, 0-9, "_", and "-" are allowed`; + } + }, + }), + { + validate: spaceIds => { + if (!spaceIds.length) { + return 'must specify one or more space ids'; + } else if (uniq(spaceIds).length !== spaceIds.length) { + return 'duplicate space ids are not allowed'; + } + }, + } + ), + object: schema.object({ + type: schema.string(), + id: schema.string(), + }), + }), + }, + }, + createLicensedRouteHandler(async (_context, request, response) => { + const [startServices] = await getStartServices(); + const scopedClient = startServices.savedObjects.getScopedClient(request); + + const spaces = request.body.spaces; + const { type, id } = request.body.object; + + try { + await scopedClient.deleteFromNamespaces(type, id, spaces); + } catch (error) { + return response.customError(wrapError(error)); + } + return response.noContent(); + }) + ); +} diff --git a/x-pack/plugins/spaces/server/saved_objects/__snapshots__/spaces_saved_objects_client.test.ts.snap b/x-pack/plugins/spaces/server/saved_objects/__snapshots__/spaces_saved_objects_client.test.ts.snap deleted file mode 100644 index 8b1a2581383555..00000000000000 --- a/x-pack/plugins/spaces/server/saved_objects/__snapshots__/spaces_saved_objects_client.test.ts.snap +++ /dev/null @@ -1,29 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`default space #bulkCreate throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`default space #bulkGet throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`default space #create throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`default space #delete throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`default space #find throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`default space #get throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`default space #update throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`space_1 space #bulkCreate throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`space_1 space #bulkGet throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`space_1 space #create throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`space_1 space #delete throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`space_1 space #find throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`space_1 space #get throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; - -exports[`space_1 space #update throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts index 2d6fe36792c403..f9961329c088b7 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts @@ -50,42 +50,41 @@ const createMockResponse = () => ({ references: [], }); +const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; + [ { id: DEFAULT_SPACE_ID, expectedNamespace: undefined }, { id: 'space_1', expectedNamespace: 'space_1' }, ].forEach(currentSpace => { describe(`${currentSpace.id} space`, () => { + const createSpacesSavedObjectsClient = async () => { + const request = createMockRequest(); + const baseClient = createMockClient(); + const spacesService = await createSpacesService(currentSpace.id); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + typeRegistry, + }); + return { client, baseClient }; + }; + describe('#get', () => { test(`throws error if options.namespace is specified`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); + const { client } = await createSpacesSavedObjectsClient(); - await expect( - client.get('foo', '', { namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + await expect(client.get('foo', '', { namespace: 'bar' })).rejects.toThrow( + ERROR_NAMESPACE_SPECIFIED + ); }); - test(`supplements options with undefined namespace`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); const expectedReturnValue = createMockResponse(); baseClient.get.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesService = await createSpacesService(currentSpace.id); - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); const type = Symbol(); const id = Symbol(); const options = Object.freeze({ foo: 'bar' }); @@ -102,37 +101,17 @@ const createMockResponse = () => ({ describe('#bulkGet', () => { test(`throws error if options.namespace is specified`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); + const { client } = await createSpacesSavedObjectsClient(); await expect( client.bulkGet([{ id: '', type: 'foo' }], { namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); - test(`supplements options with undefined namespace`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const expectedReturnValue = { - saved_objects: [createMockResponse()], - }; + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); + const expectedReturnValue = { saved_objects: [createMockResponse()] }; baseClient.bulkGet.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); const objects = [{ type: 'foo' }]; const options = Object.freeze({ foo: 'bar' }); @@ -149,25 +128,15 @@ const createMockResponse = () => ({ describe('#find', () => { test(`throws error if options.namespace is specified`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); + const { client } = await createSpacesSavedObjectsClient(); - await expect( - client.find({ type: 'foo', namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + await expect(client.find({ type: 'foo', namespace: 'bar' })).rejects.toThrow( + ERROR_NAMESPACE_SPECIFIED + ); }); test(`passes options.type to baseClient if valid singular type specified`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); + const { client, baseClient } = await createSpacesSavedObjectsClient(); const expectedReturnValue = { saved_objects: [createMockResponse()], total: 1, @@ -175,16 +144,8 @@ const createMockResponse = () => ({ page: 0, }; baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesService = await createSpacesService(currentSpace.id); - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); const options = Object.freeze({ type: 'foo' }); - const actualReturnValue = await client.find(options); expect(actualReturnValue).toBe(expectedReturnValue); @@ -194,9 +155,8 @@ const createMockResponse = () => ({ }); }); - test(`supplements options with undefined namespace`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); const expectedReturnValue = { saved_objects: [createMockResponse()], total: 1, @@ -204,14 +164,6 @@ const createMockResponse = () => ({ page: 0, }; baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); const options = Object.freeze({ type: ['foo', 'bar'] }); const actualReturnValue = await client.find(options); @@ -226,35 +178,17 @@ const createMockResponse = () => ({ describe('#create', () => { test(`throws error if options.namespace is specified`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); + const { client } = await createSpacesSavedObjectsClient(); - await expect( - client.create('foo', {}, { namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + await expect(client.create('foo', {}, { namespace: 'bar' })).rejects.toThrow( + ERROR_NAMESPACE_SPECIFIED + ); }); - test(`supplements options with undefined namespace`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); const expectedReturnValue = createMockResponse(); baseClient.create.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); const type = Symbol(); const attributes = Symbol(); @@ -272,37 +206,17 @@ const createMockResponse = () => ({ describe('#bulkCreate', () => { test(`throws error if options.namespace is specified`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); + const { client } = await createSpacesSavedObjectsClient(); await expect( client.bulkCreate([{ id: '', type: 'foo', attributes: {} }], { namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); - test(`supplements options with undefined namespace`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const expectedReturnValue = { - saved_objects: [createMockResponse()], - }; + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); + const expectedReturnValue = { saved_objects: [createMockResponse()] }; baseClient.bulkCreate.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); const objects = [{ type: 'foo' }]; const options = Object.freeze({ foo: 'bar' }); @@ -319,36 +233,18 @@ const createMockResponse = () => ({ describe('#update', () => { test(`throws error if options.namespace is specified`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); + const { client } = await createSpacesSavedObjectsClient(); await expect( // @ts-ignore client.update(null, null, null, { namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); - test(`supplements options with undefined namespace`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); const expectedReturnValue = createMockResponse(); baseClient.update.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); const type = Symbol(); const id = Symbol(); @@ -366,21 +262,19 @@ const createMockResponse = () => ({ }); 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); + test(`throws error if options.namespace is specified`, async () => { + const { client } = await createSpacesSavedObjectsClient(); - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); + await expect( + // @ts-ignore + client.bulkUpdate(null, { namespace: 'bar' }) + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); + const expectedReturnValue = { saved_objects: [createMockResponse()] }; + baseClient.bulkUpdate.mockReturnValue(Promise.resolve(expectedReturnValue)); const actualReturnValue = await client.bulkUpdate([ { id: 'id', type: 'foo', attributes: {}, references: [] }, @@ -403,36 +297,18 @@ const createMockResponse = () => ({ describe('#delete', () => { test(`throws error if options.namespace is specified`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); + const { client } = await createSpacesSavedObjectsClient(); await expect( // @ts-ignore client.delete(null, null, { namespace: 'bar' }) - ).rejects.toThrowErrorMatchingSnapshot(); + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); }); - test(`supplements options with undefined namespace`, async () => { - const request = createMockRequest(); - const baseClient = createMockClient(); + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); const expectedReturnValue = createMockResponse(); baseClient.delete.mockReturnValue(Promise.resolve(expectedReturnValue)); - const spacesService = await createSpacesService(currentSpace.id); - - const client = new SpacesSavedObjectsClient({ - request, - baseClient, - spacesService, - typeRegistry, - }); const type = Symbol(); const id = Symbol(); @@ -447,5 +323,65 @@ const createMockResponse = () => ({ }); }); }); + + describe('#addToNamespaces', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = await createSpacesSavedObjectsClient(); + + await expect( + // @ts-ignore + client.addToNamespaces(null, null, null, { namespace: 'bar' }) + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); + const expectedReturnValue = createMockResponse(); + baseClient.addToNamespaces.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const type = Symbol(); + const id = Symbol(); + const namespaces = Symbol(); + const options = Object.freeze({ foo: 'bar' }); + // @ts-ignore + const actualReturnValue = await client.addToNamespaces(type, id, namespaces, options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.addToNamespaces).toHaveBeenCalledWith(type, id, namespaces, { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + + describe('#deleteFromNamespaces', () => { + test(`throws error if options.namespace is specified`, async () => { + const { client } = await createSpacesSavedObjectsClient(); + + await expect( + // @ts-ignore + client.deleteFromNamespaces(null, null, null, { namespace: 'bar' }) + ).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED); + }); + + test(`supplements options with the current namespace`, async () => { + const { client, baseClient } = await createSpacesSavedObjectsClient(); + const expectedReturnValue = createMockResponse(); + baseClient.deleteFromNamespaces.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const type = Symbol(); + const id = Symbol(); + const namespaces = Symbol(); + const options = Object.freeze({ foo: 'bar' }); + // @ts-ignore + const actualReturnValue = await client.deleteFromNamespaces(type, id, namespaces, options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.deleteFromNamespaces).toHaveBeenCalledWith(type, id, namespaces, { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); }); }); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index f216d5743cf89e..e31bc7cef69001 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -13,6 +13,8 @@ import { SavedObjectsCreateOptions, SavedObjectsFindOptions, SavedObjectsUpdateOptions, + SavedObjectsAddToNamespacesOptions, + SavedObjectsDeleteFromNamespacesOptions, ISavedObjectTypeRegistry, } from 'src/core/server'; import { SpacesServiceSetup } from '../spaces_service/spaces_service'; @@ -213,6 +215,50 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { }); } + /** + * Adds namespaces to a SavedObject + * + * @param type + * @param id + * @param namespaces + * @param options + */ + public async addToNamespaces( + type: string, + id: string, + namespaces: string[], + options: SavedObjectsAddToNamespacesOptions = {} + ) { + throwErrorIfNamespaceSpecified(options); + + return await this.client.addToNamespaces(type, id, namespaces, { + ...options, + namespace: spaceIdToNamespace(this.spaceId), + }); + } + + /** + * Removes namespaces from a SavedObject + * + * @param type + * @param id + * @param namespaces + * @param options + */ + public async deleteFromNamespaces( + type: string, + id: string, + namespaces: string[], + options: SavedObjectsDeleteFromNamespacesOptions = {} + ) { + throwErrorIfNamespaceSpecified(options); + + return await this.client.deleteFromNamespaces(type, id, namespaces, { + ...options, + namespace: spaceIdToNamespace(this.spaceId), + }); + } + /** * Updates an array of objects by id * diff --git a/x-pack/plugins/uptime/server/rest_api/types.ts b/x-pack/plugins/uptime/server/rest_api/types.ts index aecb099b7bed5c..e05e7a4d7faf1a 100644 --- a/x-pack/plugins/uptime/server/rest_api/types.ts +++ b/x-pack/plugins/uptime/server/rest_api/types.ts @@ -10,7 +10,7 @@ import { RouteConfig, RouteMethod, CallAPIOptions, - SavedObjectsClient, + SavedObjectsClientContract, RequestHandlerContext, KibanaRequest, KibanaResponseFactory, @@ -69,18 +69,7 @@ export interface UMRouteParams { options?: CallAPIOptions | undefined ) => Promise; dynamicSettings: DynamicSettings; - savedObjectsClient: Pick< - SavedObjectsClient, - | 'errors' - | 'create' - | 'bulkCreate' - | 'delete' - | 'find' - | 'bulkGet' - | 'get' - | 'update' - | 'bulkUpdate' - >; + savedObjectsClient: SavedObjectsClientContract; } /** diff --git a/x-pack/test/api_integration/apis/spaces/saved_objects.ts b/x-pack/test/api_integration/apis/spaces/saved_objects.ts index 05f14a50f2170e..7584be7fd84984 100644 --- a/x-pack/test/api_integration/apis/spaces/saved_objects.ts +++ b/x-pack/test/api_integration/apis/spaces/saved_objects.ts @@ -89,7 +89,7 @@ export default function({ getService }: FtrProviderContext) { .expect(404) .then((response: Record) => { expect(response.body).to.eql({ - message: 'Not Found', + message: 'Saved object [space/default] not found', statusCode: 404, error: 'Not Found', }); diff --git a/x-pack/test/saved_object_api_integration/common/config.ts b/x-pack/test/saved_object_api_integration/common/config.ts index dda7934ce875ad..eaf7230a832a80 100644 --- a/x-pack/test/saved_object_api_integration/common/config.ts +++ b/x-pack/test/saved_object_api_integration/common/config.ts @@ -56,8 +56,10 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) ...config.xpack.api.get('kbnTestServer.serverArgs'), '--optimize.enabled=false', '--server.xsrf.disableProtection=true', + `--plugin-path=${path.join(__dirname, 'fixtures', 'isolated_type_plugin')}`, `--plugin-path=${path.join(__dirname, 'fixtures', 'namespace_agnostic_type_plugin')}`, `--plugin-path=${path.join(__dirname, 'fixtures', 'hidden_type_plugin')}`, + `--plugin-path=${path.join(__dirname, 'fixtures', 'shared_type_plugin')}`, ...disabledPlugins.map(key => `--xpack.${key}.enabled=false`), ], }, diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index 34361ad9df542d..d2c14189e2529a 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -53,7 +53,7 @@ { "type": "doc", "value": { - "id": "index-pattern:91200a00-9efd-11e7-acb3-3dab96693fab", + "id": "index-pattern:defaultspace-index-pattern-id", "index": ".kibana", "source": { "index-pattern": { @@ -76,7 +76,7 @@ "source": { "config": { "buildNum": 8467, - "defaultIndex": "91200a00-9efd-11e7-acb3-3dab96693fab" + "defaultIndex": "defaultspace-index-pattern-id" }, "type": "config", "updated_at": "2017-09-21T18:49:16.302Z" @@ -88,15 +88,15 @@ { "type": "doc", "value": { - "id": "visualization:dd7caf20-9efd-11e7-acb3-3dab96693fab", + "id": "isolatedtype:defaultspace-isolatedtype-id", "index": ".kibana", "source": { - "type": "visualization", + "type": "isolatedtype", "updated_at": "2017-09-21T18:51:23.794Z", - "visualization": { + "isolatedtype": { "description": "", "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"index\":\"91200a00-9efd-11e7-acb3-3dab96693fab\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + "searchSourceJSON": "{\"index\":\"defaultspace-index-pattern-id\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" }, "title": "Count of requests", "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", @@ -111,7 +111,7 @@ { "type": "doc", "value": { - "id": "dashboard:be3733a0-9efe-11e7-acb3-3dab96693fab", + "id": "dashboard:defaultspace-dashboard-id", "index": ".kibana", "source": { "dashboard": { @@ -121,7 +121,7 @@ "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" }, "optionsJSON": "{}", - "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"dd7caf20-9efd-11e7-acb3-3dab96693fab\",\"col\":1,\"row\":1}]", + "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"isolatedtype\",\"id\":\"defaultspace-isolatedtype-id\",\"col\":1,\"row\":1}]", "refreshInterval": { "display": "Off", "pause": false, @@ -144,7 +144,7 @@ { "type": "doc", "value": { - "id": "space_1:index-pattern:space_1-91200a00-9efd-11e7-acb3-3dab96693fab", + "id": "space_1:index-pattern:space1-index-pattern-id", "index": ".kibana", "source": { "index-pattern": { @@ -168,7 +168,7 @@ "source": { "config": { "buildNum": 8467, - "defaultIndex": "91200a00-9efd-11e7-acb3-3dab96693fab" + "defaultIndex": "defaultspace-index-pattern-id" }, "namespace": "space_1", "type": "config", @@ -181,16 +181,16 @@ { "type": "doc", "value": { - "id": "space_1:visualization:space_1-dd7caf20-9efd-11e7-acb3-3dab96693fab", + "id": "space_1:isolatedtype:space1-isolatedtype-id", "index": ".kibana", "source": { "namespace": "space_1", - "type": "visualization", + "type": "isolatedtype", "updated_at": "2017-09-21T18:51:23.794Z", - "visualization": { + "isolatedtype": { "description": "", "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"index\":\"space_1-91200a00-9efd-11e7-acb3-3dab96693fab\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + "searchSourceJSON": "{\"index\":\"space1-index-pattern-id\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" }, "title": "Count of requests", "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", @@ -205,7 +205,7 @@ { "type": "doc", "value": { - "id": "space_1:dashboard:space_1-be3733a0-9efe-11e7-acb3-3dab96693fab", + "id": "space_1:dashboard:space1-dashboard-id", "index": ".kibana", "source": { "dashboard": { @@ -215,7 +215,7 @@ "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" }, "optionsJSON": "{}", - "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"space_1-dd7caf20-9efd-11e7-acb3-3dab96693fab\",\"col\":1,\"row\":1}]", + "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"isolatedtype\",\"id\":\"space1-isolatedtype-id\",\"col\":1,\"row\":1}]", "refreshInterval": { "display": "Off", "pause": false, @@ -239,7 +239,7 @@ { "type": "doc", "value": { - "id": "space_2:index-pattern:space_2-91200a00-9efd-11e7-acb3-3dab96693fab", + "id": "space_2:index-pattern:space2-index-pattern-id", "index": ".kibana", "source": { "index-pattern": { @@ -263,7 +263,7 @@ "source": { "config": { "buildNum": 8467, - "defaultIndex": "91200a00-9efd-11e7-acb3-3dab96693fab" + "defaultIndex": "defaultspace-index-pattern-id" }, "namespace": "space_2", "type": "config", @@ -276,16 +276,16 @@ { "type": "doc", "value": { - "id": "space_2:visualization:space_2-dd7caf20-9efd-11e7-acb3-3dab96693fab", + "id": "space_2:isolatedtype:space2-isolatedtype-id", "index": ".kibana", "source": { "namespace": "space_2", - "type": "visualization", + "type": "isolatedtype", "updated_at": "2017-09-21T18:51:23.794Z", - "visualization": { + "isolatedtype": { "description": "", "kibanaSavedObjectMeta": { - "searchSourceJSON": "{\"index\":\"space_2-91200a00-9efd-11e7-acb3-3dab96693fab\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + "searchSourceJSON": "{\"index\":\"space2-index-pattern-id\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" }, "title": "Count of requests", "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", @@ -300,7 +300,7 @@ { "type": "doc", "value": { - "id": "space_2:dashboard:space_2-be3733a0-9efe-11e7-acb3-3dab96693fab", + "id": "space_2:dashboard:space2-dashboard-id", "index": ".kibana", "source": { "dashboard": { @@ -310,7 +310,7 @@ "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" }, "optionsJSON": "{}", - "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"space_2-dd7caf20-9efd-11e7-acb3-3dab96693fab\",\"col\":1,\"row\":1}]", + "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"isolatedtype\",\"id\":\"space2-isolatedtype-id\",\"col\":1,\"row\":1}]", "refreshInterval": { "display": "Off", "pause": false, @@ -334,11 +334,11 @@ { "type": "doc", "value": { - "id": "globaltype:8121a00-8efd-21e7-1cb3-34ab966434445", + "id": "globaltype:globaltype-id", "index": ".kibana", "source": { "globaltype": { - "name": "My favorite global object" + "title": "My favorite global object" }, "type": "globaltype", "updated_at": "2017-09-21T18:59:16.270Z" @@ -346,3 +346,54 @@ "type": "doc" } } + +{ + "type": "doc", + "value": { + "id": "sharedtype:default_and_space_1", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in the default and space_1 spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:only_space_1", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object only in space_1" + }, + "type": "sharedtype", + "namespaces": ["space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:only_space_2", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object only in space_2" + }, + "type": "sharedtype", + "namespaces": ["space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index c2489f2a906c88..7b5b1d86f6bcc8 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -82,7 +82,7 @@ }, "globaltype": { "properties": { - "name": { + "title": { "fields": { "keyword": { "ignore_above": 2048, @@ -147,9 +147,41 @@ } } }, + "isolatedtype": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + }, "namespace": { "type": "keyword" }, + "namespaces": { + "type": "keyword" + }, "search": { "properties": { "columns": { @@ -186,6 +218,19 @@ } } }, + "sharedtype": { + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, "space": { "properties": { "_reserved": { @@ -282,35 +327,6 @@ "type": "text" } } - }, - "visualization": { - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "savedSearchId": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - }, - "visState": { - "type": "text" - } - } } } }, @@ -322,4 +338,4 @@ } } } -} \ No newline at end of file +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/hidden_type_plugin/index.js b/x-pack/test/saved_object_api_integration/common/fixtures/hidden_type_plugin/index.js index ea32811794c47f..5989db84e22902 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/hidden_type_plugin/index.js +++ b/x-pack/test/saved_object_api_integration/common/fixtures/hidden_type_plugin/index.js @@ -21,5 +21,7 @@ export default function(kibana) { }, config() {}, + + init() {}, // need empty init for plugin to load }); } diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/hidden_type_plugin/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/hidden_type_plugin/mappings.json index e4815273964a1a..45f898e10e2bac 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/hidden_type_plugin/mappings.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/hidden_type_plugin/mappings.json @@ -1,7 +1,7 @@ { "hiddentype": { "properties": { - "name": { + "title": { "type": "text", "fields": { "keyword": { diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/isolated_type_plugin/index.js b/x-pack/test/saved_object_api_integration/common/fixtures/isolated_type_plugin/index.js new file mode 100644 index 00000000000000..a406c6737da5f5 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/fixtures/isolated_type_plugin/index.js @@ -0,0 +1,26 @@ +/* + * 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 mappings from './mappings.json'; + +export default function(kibana) { + return new kibana.Plugin({ + require: ['kibana', 'elasticsearch', 'xpack_main'], + name: 'isolated_type_plugin', + uiExports: { + savedObjectsManagement: { + isolatedtype: { + isImportableAndExportable: true, + }, + }, + mappings, + }, + + config() {}, + + init() {}, // need empty init for plugin to load + }); +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/isolated_type_plugin/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/isolated_type_plugin/mappings.json new file mode 100644 index 00000000000000..141ebbc93c2908 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/fixtures/isolated_type_plugin/mappings.json @@ -0,0 +1,31 @@ +{ + "isolatedtype": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/isolated_type_plugin/package.json b/x-pack/test/saved_object_api_integration/common/fixtures/isolated_type_plugin/package.json new file mode 100644 index 00000000000000..665ecb1b31d7ee --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/fixtures/isolated_type_plugin/package.json @@ -0,0 +1,7 @@ +{ + "name": "isolated_type_plugin", + "version": "0.0.0", + "kibana": { + "version": "kibana" + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/namespace_agnostic_type_plugin/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/namespace_agnostic_type_plugin/mappings.json index b30a2c3877b885..64d309b4209a20 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/namespace_agnostic_type_plugin/mappings.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/namespace_agnostic_type_plugin/mappings.json @@ -1,7 +1,7 @@ { "globaltype": { "properties": { - "name": { + "title": { "type": "text", "fields": { "keyword": { diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/shared_type_plugin/index.js b/x-pack/test/saved_object_api_integration/common/fixtures/shared_type_plugin/index.js new file mode 100644 index 00000000000000..91a24fb9f4f564 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/fixtures/shared_type_plugin/index.js @@ -0,0 +1,27 @@ +/* + * 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 mappings from './mappings.json'; + +export default function(kibana) { + return new kibana.Plugin({ + require: ['kibana', 'elasticsearch', 'xpack_main'], + name: 'shared_type_plugin', + uiExports: { + savedObjectsManagement: {}, + savedObjectSchemas: { + sharedtype: { + multiNamespace: true, + }, + }, + mappings, + }, + + config() {}, + + init() {}, // need empty init for plugin to load + }); +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/shared_type_plugin/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/shared_type_plugin/mappings.json new file mode 100644 index 00000000000000..918958aec0d6df --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/fixtures/shared_type_plugin/mappings.json @@ -0,0 +1,15 @@ +{ + "sharedtype": { + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/shared_type_plugin/package.json b/x-pack/test/saved_object_api_integration/common/fixtures/shared_type_plugin/package.json new file mode 100644 index 00000000000000..c52f4256c5c06b --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/fixtures/shared_type_plugin/package.json @@ -0,0 +1,7 @@ +{ + "name": "shared_type_plugin", + "version": "0.0.0", + "kibana": { + "version": "kibana" + } +} diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts new file mode 100644 index 00000000000000..b32950538f8e53 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts @@ -0,0 +1,40 @@ +/* + * 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. + */ + +export const SAVED_OBJECT_TEST_CASES = Object.freeze({ + SINGLE_NAMESPACE_DEFAULT_SPACE: Object.freeze({ + type: 'isolatedtype', + id: 'defaultspace-isolatedtype-id', + }), + SINGLE_NAMESPACE_SPACE_1: Object.freeze({ + type: 'isolatedtype', + id: 'space1-isolatedtype-id', + }), + SINGLE_NAMESPACE_SPACE_2: Object.freeze({ + type: 'isolatedtype', + id: 'space2-isolatedtype-id', + }), + MULTI_NAMESPACE_DEFAULT_AND_SPACE_1: Object.freeze({ + type: 'sharedtype', + id: 'default_and_space_1', + }), + MULTI_NAMESPACE_ONLY_SPACE_1: Object.freeze({ + type: 'sharedtype', + id: 'only_space_1', + }), + MULTI_NAMESPACE_ONLY_SPACE_2: Object.freeze({ + type: 'sharedtype', + id: 'only_space_2', + }), + NAMESPACE_AGNOSTIC: Object.freeze({ + type: 'globaltype', + id: 'globaltype-id', + }), + HIDDEN: Object.freeze({ + type: 'hiddentype', + id: 'any', + }), +}); diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts new file mode 100644 index 00000000000000..5640dfefa4f8db --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts @@ -0,0 +1,302 @@ +/* + * 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 { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; +import { SAVED_OBJECT_TEST_CASES as CASES } from './saved_object_test_cases'; +import { SPACES } from './spaces'; +import { AUTHENTICATION } from './authentication'; +import { TestCase, TestUser, ExpectResponseBody } from './types'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { + NOT_A_KIBANA_USER, + SUPERUSER, + KIBANA_LEGACY_USER, + KIBANA_DUAL_PRIVILEGES_USER, + KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + KIBANA_RBAC_USER, + KIBANA_RBAC_DASHBOARD_ONLY_USER, + KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + KIBANA_RBAC_SPACE_1_ALL_USER, + KIBANA_RBAC_SPACE_1_READ_USER, +} = AUTHENTICATION; + +export function getUrlPrefix(spaceId: string) { + return spaceId && spaceId !== DEFAULT_SPACE_ID ? `/s/${spaceId}` : ``; +} + +export function getExpectedSpaceIdProperty(spaceId: string) { + if (spaceId === DEFAULT_SPACE_ID) { + return {}; + } + return { + spaceId, + }; +} + +export const getTestTitle = ( + testCaseOrCases: TestCase | TestCase[], + bulkStatusCode?: 200 | 403 // only used for bulk test suites; other suites specify forbidden/permitted in each test case +) => { + const testCases = Array.isArray(testCaseOrCases) ? testCaseOrCases : [testCaseOrCases]; + const stringify = (array: TestCase[]) => array.map(x => `${x.type}/${x.id}`).join(); + if (bulkStatusCode === 403 || (testCases.length === 1 && testCases[0].failure === 403)) { + return `forbidden [${stringify(testCases)}]`; + } + if (testCases.find(x => x.failure === 403)) { + throw new Error( + 'Cannot create test title for multiple forbidden test cases; specify individual tests for each of these test cases' + ); + } + // permitted + const list: string[] = []; + Object.entries({ + success: undefined, + 'bad request': 400, + 'not found': 404, + conflict: 409, + }).forEach(([descriptor, failure]) => { + const filtered = testCases.filter(x => x.failure === failure); + if (filtered.length) { + list.push(`${descriptor} [${stringify(filtered)}]`); + } + }); + return `${list.join(' and ')}`; +}; + +export const testCaseFailures = { + // test suites need explicit return types for number primitives + fail400: (condition?: boolean): { failure?: 400 } => + condition !== false ? { failure: 400 } : {}, + fail404: (condition?: boolean): { failure?: 404 } => + condition !== false ? { failure: 404 } : {}, + fail409: (condition?: boolean): { failure?: 409 } => + condition !== false ? { failure: 409 } : {}, +}; + +/** + * Test cases have additional properties that we don't want to send in HTTP Requests + */ +export const createRequest = ({ type, id }: TestCase) => ({ type, id }); + +const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); +const isNamespaceAgnostic = (type: string) => type === 'globaltype'; +const isMultiNamespace = (type: string) => type === 'sharedtype'; +export const expectResponses = { + forbidden: (action: string) => (typeOrTypes: string | string[]): ExpectResponseBody => async ( + response: Record + ) => { + const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; + const uniqueSorted = uniq(types).sort(); + expect(response.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to ${action} ${uniqueSorted.join()}`, + }); + }, + permitted: async (object: Record, testCase: TestCase) => { + const { type, id, failure } = testCase; + if (failure) { + let error: ReturnType; + if (failure === 400) { + error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type); + } else if (failure === 404) { + error = SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } else if (failure === 409) { + error = SavedObjectsErrorHelpers.createConflictError(type, id); + } else { + throw new Error(`Encountered unexpected error code ${failure}`); + } + // should not call permitted with a 403 failure case + if (object.type && object.id) { + // bulk request error + expect(object.type).to.eql(type); + expect(object.id).to.eql(id); + expect(object.error).to.eql(error.output.payload); + } else { + // non-bulk request error + expect(object.error).to.eql(error.output.payload.error); + expect(object.statusCode).to.eql(error.output.payload.statusCode); + // ignore the error.message, because it can vary for decorated non-bulk errors (e.g., conflict) + } + } else { + // fall back to default behavior of testing the success outcome + expect(object.type).to.eql(type); + if (id) { + expect(object.id).to.eql(id); + } else { + // created an object without specifying the ID, so it was auto-generated + expect(object.id).to.match(/^[0-9a-f-]{36}$/); + } + expect(object).not.to.have.property('error'); + expect(object.updated_at).to.match(/^[\d-]{10}T[\d:\.]{12}Z$/); + // don't test attributes, version, or references + } + }, + /** + * Additional assertions that we use in `bulk_create` and `create` to ensure that + * newly-created (or overwritten) objects don't have unexpected properties + */ + successCreated: async (es: any, spaceId: string, type: string, id: string) => { + const isNamespaceUndefined = + spaceId === SPACES.DEFAULT.spaceId || isNamespaceAgnostic(type) || isMultiNamespace(type); + const expectedSpacePrefix = isNamespaceUndefined ? '' : `${spaceId}:`; + const savedObject = await es.get({ + id: `${expectedSpacePrefix}${type}:${id}`, + index: '.kibana', + }); + const { namespace: actualNamespace, namespaces: actualNamespaces } = savedObject._source; + if (isNamespaceUndefined) { + expect(actualNamespace).to.eql(undefined); + } else { + expect(actualNamespace).to.eql(spaceId); + } + if (isMultiNamespace(type)) { + if (id === CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1.id) { + expect(actualNamespaces).to.eql([DEFAULT_SPACE_ID, SPACE_1_ID]); + } else if (id === CASES.MULTI_NAMESPACE_ONLY_SPACE_1.id) { + expect(actualNamespaces).to.eql([SPACE_1_ID]); + } else if (id === CASES.MULTI_NAMESPACE_ONLY_SPACE_2.id) { + expect(actualNamespaces).to.eql([SPACE_2_ID]); + } else { + // newly created in this space + expect(actualNamespaces).to.eql([spaceId]); + } + } + return savedObject; + }, +}; + +/** + * Get test scenarios for each type of suite. + * @param modifier Use this to generate additional permutations of test scenarios. + * For instance, a modifier of ['foo', 'bar'] would return + * a `securityAndSpaces` of: [ + * { spaceId: DEFAULT_SPACE_ID, users: {...}, modifier: 'foo' }, + * { spaceId: DEFAULT_SPACE_ID, users: {...}, modifier: 'bar' }, + * { spaceId: SPACE_1_ID, users: {...}, modifier: 'foo' }, + * { spaceId: SPACE_1_ID, users: {...}, modifier: 'bar' }, + * ] + */ +export const getTestScenarios = (modifiers?: T[]) => { + const commonUsers = { + noAccess: { ...NOT_A_KIBANA_USER, description: 'user with no access' }, + superuser: { ...SUPERUSER, description: 'superuser' }, + legacyAll: { ...KIBANA_LEGACY_USER, description: 'legacy user' }, + allGlobally: { ...KIBANA_RBAC_USER, description: 'rbac user with all globally' }, + readGlobally: { + ...KIBANA_RBAC_DASHBOARD_ONLY_USER, + description: 'rbac user with read globally', + }, + dualAll: { ...KIBANA_DUAL_PRIVILEGES_USER, description: 'dual-privileges user' }, + dualRead: { + ...KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + description: 'dual-privileges readonly user', + }, + }; + + interface Security { + modifier?: T; + users: Record< + | keyof typeof commonUsers + | 'allAtDefaultSpace' + | 'readAtDefaultSpace' + | 'allAtSpace1' + | 'readAtSpace1', + TestUser + >; + } + interface SecurityAndSpaces { + modifier?: T; + users: Record< + keyof typeof commonUsers | 'allAtSpace' | 'readAtSpace' | 'allAtOtherSpace', + TestUser + >; + spaceId: string; + } + interface Spaces { + modifier?: T; + spaceId: string; + } + + let spaces: Spaces[] = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID].map(x => ({ spaceId: x })); + let security: Security[] = [ + { + users: { + ...commonUsers, + allAtDefaultSpace: { + ...KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + description: 'rbac user with all at default space', + }, + readAtDefaultSpace: { + ...KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + description: 'rbac user with read at default space', + }, + allAtSpace1: { + ...KIBANA_RBAC_SPACE_1_ALL_USER, + description: 'rbac user with all at space_1', + }, + readAtSpace1: { + ...KIBANA_RBAC_SPACE_1_READ_USER, + description: 'rbac user with read at space_1', + }, + }, + }, + ]; + let securityAndSpaces: SecurityAndSpaces[] = [ + { + spaceId: DEFAULT_SPACE_ID, + users: { + ...commonUsers, + allAtSpace: { + ...KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + description: 'user with all at the space', + }, + readAtSpace: { + ...KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + description: 'user with read at the space', + }, + allAtOtherSpace: { + ...KIBANA_RBAC_SPACE_1_ALL_USER, + description: 'user with all at other space', + }, + }, + }, + { + spaceId: SPACE_1_ID, + users: { + ...commonUsers, + allAtSpace: { ...KIBANA_RBAC_SPACE_1_ALL_USER, description: 'user with all at the space' }, + readAtSpace: { + ...KIBANA_RBAC_SPACE_1_READ_USER, + description: 'user with read at the space', + }, + allAtOtherSpace: { + ...KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + description: 'user with all at other space', + }, + }, + }, + ]; + if (modifiers) { + const addModifier = (list: T[]) => + list.map(x => modifiers.map(modifier => ({ ...x, modifier }))).flat(); + spaces = addModifier(spaces); + security = addModifier(security); + securityAndSpaces = addModifier(securityAndSpaces); + } + return { + spaces, + security, + securityAndSpaces, + }; +}; diff --git a/x-pack/test/saved_object_api_integration/common/lib/space_test_utils.ts b/x-pack/test/saved_object_api_integration/common/lib/space_test_utils.ts deleted file mode 100644 index 1619d77761c842..00000000000000 --- a/x-pack/test/saved_object_api_integration/common/lib/space_test_utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; - -export function getUrlPrefix(spaceId: string) { - return spaceId && spaceId !== DEFAULT_SPACE_ID ? `/s/${spaceId}` : ``; -} - -export function getIdPrefix(spaceId: string) { - return spaceId === DEFAULT_SPACE_ID ? '' : `${spaceId}-`; -} - -export function getExpectedSpaceIdProperty(spaceId: string) { - if (spaceId === DEFAULT_SPACE_ID) { - return {}; - } - return { - spaceId, - }; -} diff --git a/x-pack/test/saved_object_api_integration/common/lib/types.ts b/x-pack/test/saved_object_api_integration/common/lib/types.ts index 487afff1494c0d..f6e6d391ae9052 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/types.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/types.ts @@ -4,9 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ -export type DescribeFn = (text: string, fn: () => void) => void; +export type ExpectResponseBody = (response: Record) => Promise; -export interface TestDefinitionAuthentication { - username?: string; - password?: string; +export interface TestDefinition { + title: string; + responseStatusCode: number; + responseBody: ExpectResponseBody; +} + +export interface TestSuite { + user?: TestUser; + spaceId?: string; + tests: T[]; +} + +export interface TestCase { + type: string; + id: string; + failure?: 400 | 403 | 404 | 409; +} + +export interface TestUser { + username: string; + password: string; + description: string; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts index b6f1bb956d72dc..0dafe6b7b386df 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts @@ -6,224 +6,137 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; - -interface BulkCreateTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; -} - -interface BulkCreateCustomTest extends BulkCreateTest { - description: string; - requestBody: { - [key: string]: any; - }; -} - -interface BulkCreateTests { - default: BulkCreateTest; - includingSpace: BulkCreateTest; - custom?: BulkCreateCustomTest; +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 { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; + +export interface BulkCreateTestDefinition extends TestDefinition { + request: Array<{ type: string; id: string }>; + overwrite: boolean; } - -interface BulkCreateTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - tests: BulkCreateTests; +export type BulkCreateTestSuite = TestSuite; +export interface BulkCreateTestCase extends TestCase { + failure?: 400 | 409; // only used for permitted response case } -const createBulkRequests = (spaceId: string) => [ - { - type: 'visualization', - id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - attributes: { - title: 'An existing visualization', - }, - }, - { - type: 'dashboard', - id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, - attributes: { - title: 'A great new dashboard', - }, - }, - { - type: 'globaltype', - id: '05976c65-1145-4858-bbf0-d225cc78a06e', - attributes: { - name: 'A new globaltype object', - }, - }, - { - type: 'globaltype', - id: '8121a00-8efd-21e7-1cb3-34ab966434445', - attributes: { - name: 'An existing globaltype', - }, - }, -]; +const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake +const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; -const isGlobalType = (type: string) => type === 'globaltype'; +const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: 'new-dashboard-id' }); +const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); +const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }); +export const TEST_CASES = Object.freeze({ + ...CASES, + NEW_SINGLE_NAMESPACE_OBJ, + NEW_MULTI_NAMESPACE_OBJ, + NEW_NAMESPACE_AGNOSTIC_OBJ, +}); export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { - const createExpectResults = (spaceId = DEFAULT_SPACE_ID) => async (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - saved_objects: [ - { - type: 'visualization', - id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - error: { - message: 'version conflict, document already exists', - statusCode: 409, - }, - }, - { - type: 'dashboard', - id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, - updated_at: resp.body.saved_objects[1].updated_at, - version: resp.body.saved_objects[1].version, - attributes: { - title: 'A great new dashboard', - }, - references: [], - }, - { - type: 'globaltype', - id: `05976c65-1145-4858-bbf0-d225cc78a06e`, - updated_at: resp.body.saved_objects[2].updated_at, - version: resp.body.saved_objects[2].version, - attributes: { - name: 'A new globaltype object', - }, - references: [], - }, - { - type: 'globaltype', - id: '8121a00-8efd-21e7-1cb3-34ab966434445', - error: { - message: 'version conflict, document already exists', - statusCode: 409, - }, - }, - ], - }); - - for (const savedObject of createBulkRequests(spaceId)) { - const expectedSpacePrefix = - spaceId === DEFAULT_SPACE_ID || isGlobalType(savedObject.type) ? '' : `${spaceId}:`; - - // query ES directory to ensure namespace was or wasn't specified - const { _source } = await es.get({ - id: `${expectedSpacePrefix}${savedObject.type}:${savedObject.id}`, - index: '.kibana', - }); - - const { namespace: actualNamespace } = _source; - - if (spaceId === DEFAULT_SPACE_ID || isGlobalType(savedObject.type)) { - expect(actualNamespace).to.eql(undefined); - } else { - expect(actualNamespace).to.eql(spaceId); + const expectForbidden = expectResponses.forbidden('bulk_create'); + const expectResponseBody = ( + testCases: BulkCreateTestCase | BulkCreateTestCase[], + statusCode: 200 | 403, + spaceId = SPACES.DEFAULT.spaceId + ): ExpectResponseBody => async (response: Record) => { + const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; + if (statusCode === 403) { + const types = testCaseArray.map(x => x.type); + await expectForbidden(types)(response); + } else { + // permitted + const savedObjects = response.body.saved_objects; + expect(savedObjects).length(testCaseArray.length); + for (let i = 0; i < savedObjects.length; i++) { + const object = savedObjects[i]; + const testCase = testCaseArray[i]; + await expectResponses.permitted(object, testCase); + if (!testCase.failure) { + expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + await expectResponses.successCreated(es, spaceId, object.type, object.id); + } } } }; - - const expectBadRequestForHiddenType = (resp: { [key: string]: any }) => { - const spaceEntry = resp.body.saved_objects.find( - (entry: any) => entry.id === 'my-hiddentype' && entry.type === 'hiddentype' - ); - expect(spaceEntry).to.eql({ - id: 'my-hiddentype', - type: 'hiddentype', - error: { - message: "Unsupported saved object type: 'hiddentype': Bad Request", - statusCode: 400, - error: 'Bad Request', + const createTestDefinitions = ( + testCases: BulkCreateTestCase | BulkCreateTestCase[], + forbidden: boolean, + overwrite: boolean, + options?: { + spaceId?: string; + singleRequest?: boolean; + responseBodyOverride?: ExpectResponseBody; + } + ): BulkCreateTestDefinition[] => { + const cases = Array.isArray(testCases) ? testCases : [testCases]; + const responseStatusCode = forbidden ? 403 : 200; + if (!options?.singleRequest) { + // if we are testing cases that should result in a forbidden response, we can do each case individually + // this ensures that multiple test cases of a single type will each result in a forbidden error + return cases.map(x => ({ + title: getTestTitle(x, responseStatusCode), + request: [createRequest(x)], + responseStatusCode, + responseBody: + options?.responseBodyOverride || + expectResponseBody(x, responseStatusCode, options?.spaceId), + overwrite, + })); + } + // batch into a single request to save time during test execution + return [ + { + title: getTestTitle(cases, responseStatusCode), + request: cases.map(x => createRequest(x)), + responseStatusCode, + responseBody: + options?.responseBodyOverride || + expectResponseBody(cases, responseStatusCode, options?.spaceId), + overwrite, }, - }); + ]; }; - const expectedForbiddenTypes = ['dashboard', 'globaltype', 'visualization']; - const expectedForbiddenTypesWithHiddenType = [ - 'dashboard', - 'globaltype', - 'hiddentype', - 'visualization', - ]; - const createExpectRbacForbidden = (types: string[] = expectedForbiddenTypes) => (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to bulk_create ${types.join(',')}`, - }); - }; - - const makeBulkCreateTest = (describeFn: DescribeFn) => ( + const makeBulkCreateTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: BulkCreateTestDefinition + definition: BulkCreateTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, tests } = definition; + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`should return ${tests.default.statusCode}`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_create`) - .auth(user.username, user.password) - .send(createBulkRequests(spaceId)) - .expect(tests.default.statusCode) - .then(tests.default.response); - }); - - it(`including a hiddentype saved object should return ${tests.includingSpace.statusCode}`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_create`) - .auth(user.username, user.password) - .send( - createBulkRequests(spaceId).concat([ - { - type: 'hiddentype', - id: `my-hiddentype`, - attributes: { - name: 'My awesome hiddentype', - }, - }, - ]) - ) - .expect(tests.includingSpace.statusCode) - .then(tests.includingSpace.response); - }); + const attrs = { attributes: { [NEW_ATTRIBUTE_KEY]: NEW_ATTRIBUTE_VAL } }; - if (tests.custom) { - it(tests.custom!.description, async () => { + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const requestBody = test.request.map(x => ({ ...x, ...attrs })); + const query = test.overwrite ? '?overwrite=true' : ''; await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_create`) - .auth(user.username, user.password) - .send(tests.custom!.requestBody) - .expect(tests.custom!.statusCode) - .then(tests.custom!.response); + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_create${query}`) + .auth(user?.username, user?.password) + .send(requestBody) + .expect(test.responseStatusCode) + .then(test.responseBody); }); } }); }; - const bulkCreateTest = makeBulkCreateTest(describe); + const addTests = makeBulkCreateTest(describe); // @ts-ignore - bulkCreateTest.only = makeBulkCreateTest(describe.only); + addTests.only = makeBulkCreateTest(describe.only); return { - bulkCreateTest, - createExpectResults, - createExpectRbacForbidden, - expectBadRequestForHiddenType, - expectedForbiddenTypesWithHiddenType, + addTests, + createTestDefinitions, + expectForbidden, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts index 9c5cc375502d13..f03dac597294cd 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts @@ -6,203 +6,110 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; +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 { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; -interface BulkGetTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; +export interface BulkGetTestDefinition extends TestDefinition { + request: Array<{ type: string; id: string }>; } - -interface BulkGetTests { - default: BulkGetTest; - includingHiddenType: BulkGetTest; -} - -interface BulkGetTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - otherSpaceId?: string; - tests: BulkGetTests; +export type BulkGetTestSuite = TestSuite; +export interface BulkGetTestCase extends TestCase { + failure?: 400 | 404; // only used for permitted response case } -const createBulkRequests = (spaceId: string) => [ - { - type: 'visualization', - id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - }, - { - type: 'dashboard', - id: `${getIdPrefix(spaceId)}does not exist`, - }, - { - type: 'globaltype', - id: '8121a00-8efd-21e7-1cb3-34ab966434445', - }, -]; +const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); +export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); export function bulkGetTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const createExpectNotFoundResults = (spaceId: string) => (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - saved_objects: [ - { - id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - type: 'visualization', - error: { - statusCode: 404, - message: 'Not found', - }, - }, - { - id: `${getIdPrefix(spaceId)}does not exist`, - type: 'dashboard', - error: { - statusCode: 404, - message: 'Not found', - }, - }, - { - id: `8121a00-8efd-21e7-1cb3-34ab966434445`, - type: 'globaltype', - updated_at: '2017-09-21T18:59:16.270Z', - version: resp.body.saved_objects[2].version, - attributes: { - name: 'My favorite global object', - }, - references: [], - }, - ], - }); + const expectForbidden = expectResponses.forbidden('bulk_get'); + const expectResponseBody = ( + testCases: BulkGetTestCase | BulkGetTestCase[], + statusCode: 200 | 403 + ): ExpectResponseBody => async (response: Record) => { + const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; + if (statusCode === 403) { + const types = testCaseArray.map(x => x.type); + await expectForbidden(types)(response); + } else { + // permitted + const savedObjects = response.body.saved_objects; + expect(savedObjects).length(testCaseArray.length); + for (let i = 0; i < savedObjects.length; i++) { + const object = savedObjects[i]; + const testCase = testCaseArray[i]; + await expectResponses.permitted(object, testCase); + } + } }; - - const expectBadRequestForHiddenType = (resp: { [key: string]: any }) => { - const spaceEntry = resp.body.saved_objects.find( - (entry: any) => entry.id === 'my-hiddentype' && entry.type === 'hiddentype' - ); - expect(spaceEntry).to.eql({ - id: 'my-hiddentype', - type: 'hiddentype', - error: { - message: "Unsupported saved object type: 'hiddentype': Bad Request", - statusCode: 400, - error: 'Bad Request', + const createTestDefinitions = ( + testCases: BulkGetTestCase | BulkGetTestCase[], + forbidden: boolean, + options?: { + singleRequest?: boolean; + responseBodyOverride?: ExpectResponseBody; + } + ): BulkGetTestDefinition[] => { + const cases = Array.isArray(testCases) ? testCases : [testCases]; + const responseStatusCode = forbidden ? 403 : 200; + if (!options?.singleRequest) { + // if we are testing cases that should result in a forbidden response, we can do each case individually + // this ensures that multiple test cases of a single type will each result in a forbidden error + return cases.map(x => ({ + title: getTestTitle(x, responseStatusCode), + request: [createRequest(x)], + responseStatusCode, + responseBody: options?.responseBodyOverride || expectResponseBody(x, responseStatusCode), + })); + } + // batch into a single request to save time during test execution + return [ + { + title: getTestTitle(cases, responseStatusCode), + request: cases.map(x => createRequest(x)), + responseStatusCode, + responseBody: + options?.responseBodyOverride || expectResponseBody(cases, responseStatusCode), }, - }); + ]; }; - const expectedForbiddenTypes = ['dashboard', 'globaltype', 'visualization']; - const expectedForbiddenTypesWithHiddenType = [ - 'dashboard', - 'globaltype', - 'hiddentype', - 'visualization', - ]; - const createExpectRbacForbidden = (types: string[] = expectedForbiddenTypes) => (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to bulk_get ${types.join(',')}`, - }); - }; - - const createExpectResults = (spaceId = DEFAULT_SPACE_ID) => (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - saved_objects: [ - { - id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - type: 'visualization', - migrationVersion: resp.body.saved_objects[0].migrationVersion, - updated_at: '2017-09-21T18:51:23.794Z', - version: resp.body.saved_objects[0].version, - attributes: { - title: 'Count of requests', - description: '', - version: 1, - // cheat for some of the more complex attributes - visState: resp.body.saved_objects[0].attributes.visState, - uiStateJSON: resp.body.saved_objects[0].attributes.uiStateJSON, - kibanaSavedObjectMeta: resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta, - }, - references: [ - { - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - id: `${getIdPrefix(spaceId)}91200a00-9efd-11e7-acb3-3dab96693fab`, - }, - ], - }, - { - id: `${getIdPrefix(spaceId)}does not exist`, - type: 'dashboard', - error: { - statusCode: 404, - message: 'Not found', - }, - }, - { - id: `8121a00-8efd-21e7-1cb3-34ab966434445`, - type: 'globaltype', - updated_at: '2017-09-21T18:59:16.270Z', - version: resp.body.saved_objects[2].version, - attributes: { - name: 'My favorite global object', - }, - references: [], - }, - ], - }); - }; - - const makeBulkGetTest = (describeFn: DescribeFn) => ( + const makeBulkGetTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: BulkGetTestDefinition + definition: BulkGetTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, otherSpaceId, tests } = definition; + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`should return ${tests.default.statusCode}`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_get`) - .auth(user.username, user.password) - .send(createBulkRequests(otherSpaceId || spaceId)) - .expect(tests.default.statusCode) - .then(tests.default.response); - }); - - it(`with a hiddentype saved object included should return ${tests.includingHiddenType.statusCode}`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_get`) - .auth(user.username, user.password) - .send( - createBulkRequests(otherSpaceId || spaceId).concat([ - { - type: 'hiddentype', - id: `my-hiddentype`, - }, - ]) - ) - .expect(tests.includingHiddenType.statusCode) - .then(tests.includingHiddenType.response); - }); + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + await supertest + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_get`) + .auth(user?.username, user?.password) + .send(test.request) + .expect(test.responseStatusCode) + .then(test.responseBody); + }); + } }); }; - const bulkGetTest = makeBulkGetTest(describe); + const addTests = makeBulkGetTest(describe); // @ts-ignore - bulkGetTest.only = makeBulkGetTest(describe.only); + addTests.only = makeBulkGetTest(describe.only); return { - bulkGetTest, - createExpectNotFoundResults, - createExpectResults, - createExpectRbacForbidden, - expectBadRequestForHiddenType, - expectedForbiddenTypesWithHiddenType, + addTests, + createTestDefinitions, + expectForbidden, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts index d14c5ccbd1d0ec..e0e2118300ef4f 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts @@ -6,234 +6,119 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; - -interface BulkUpdateTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; -} - -interface BulkUpdateTests { - spaceAware: BulkUpdateTest; - notSpaceAware: BulkUpdateTest; - hiddenType: BulkUpdateTest; - doesntExist: BulkUpdateTest; +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 { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; + +export interface BulkUpdateTestDefinition extends TestDefinition { + request: Array<{ type: string; id: string }>; } - -interface BulkUpdateTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - otherSpaceId?: string; - tests: BulkUpdateTests; +export type BulkUpdateTestSuite = TestSuite; +export interface BulkUpdateTestCase extends TestCase { + failure?: 404; // only used for permitted response case } -export function bulkUpdateTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - 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 NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake +const NEW_ATTRIBUTE_VAL = `Updated attribute value ${Date.now()}`; - const expectNotSpaceAwareRbacForbidden = createExpectRbacForbidden(['globaltype']); +const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); +export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); - 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', - }, - }); +export function bulkUpdateTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const expectForbidden = expectResponses.forbidden('bulk_update'); + const expectResponseBody = ( + testCases: BulkUpdateTestCase | BulkUpdateTestCase[], + statusCode: 200 | 403 + ): ExpectResponseBody => async (response: Record) => { + const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; + if (statusCode === 403) { + const types = testCaseArray.map(x => x.type); + await expectForbidden(types)(response); + } else { + // permitted + const savedObjects = response.body.saved_objects; + expect(savedObjects).length(testCaseArray.length); + for (let i = 0; i < savedObjects.length; i++) { + const object = savedObjects[i]; + const testCase = testCaseArray[i]; + await expectResponses.permitted(object, testCase); + if (!testCase.failure) { + expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + } + } + } }; - - 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 createTestDefinitions = ( + testCases: BulkUpdateTestCase | BulkUpdateTestCase[], + forbidden: boolean, + options?: { + singleRequest?: boolean; + responseBodyOverride?: ExpectResponseBody; + } + ): BulkUpdateTestDefinition[] => { + const cases = Array.isArray(testCases) ? testCases : [testCases]; + const responseStatusCode = forbidden ? 403 : 200; + if (!options?.singleRequest) { + // if we are testing cases that should result in a forbidden response, we can do each case individually + // this ensures that multiple test cases of a single type will each result in a forbidden error + return cases.map(x => ({ + title: getTestTitle(x, responseStatusCode), + request: [createRequest(x)], + responseStatusCode, + responseBody: options?.responseBodyOverride || expectResponseBody(x, responseStatusCode), + })); + } + // batch into a single request to save time during test execution + return [ + { + title: getTestTitle(cases, responseStatusCode), + request: cases.map(x => createRequest(x)), + responseStatusCode, + responseBody: + options?.responseBodyOverride || expectResponseBody(cases, responseStatusCode), }, - }); + ]; }; - const makeBulkUpdateTest = (describeFn: DescribeFn) => ( + const makeBulkUpdateTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: BulkUpdateTestDefinition + definition: BulkUpdateTestSuite ) => { - 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}`, - }, - }); + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; 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); - }); + const attrs = { attributes: { [NEW_ATTRIBUTE_KEY]: NEW_ATTRIBUTE_VAL } }; - describe('unknown id', () => { - it(`should return ${tests.doesntExist.statusCode}`, async () => { + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const requestBody = test.request.map(x => ({ ...x, ...attrs })); 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); + .auth(user?.username, user?.password) + .send(requestBody) + .expect(test.responseStatusCode) + .then(test.responseBody); }); - }); + } }); }; - const bulkUpdateTest = makeBulkUpdateTest(describe); + const addTests = makeBulkUpdateTest(describe); // @ts-ignore - bulkUpdateTest.only = makeBulkUpdateTest(describe.only); + addTests.only = makeBulkUpdateTest(describe.only); return { - createExpectDoesntExistNotFound, - createExpectSpaceAwareNotFound, - expectSpaceNotFound: expectHiddenTypeNotFound, - expectDoesntExistRbacForbidden, - expectNotSpaceAwareRbacForbidden, - expectNotSpaceAwareResults, - expectSpaceAwareRbacForbidden, - expectSpaceAwareResults, - expectHiddenTypeRbacForbidden, - expectHiddenTypeRbacForbiddenWithGlobalAllowed, - bulkUpdateTest, + addTests, + createTestDefinitions, + expectForbidden, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/create.ts b/x-pack/test/saved_object_api_integration/common/suites/create.ts index 29960c513d40f2..f657756be92cdb 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/create.ts @@ -3,206 +3,117 @@ * 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 '../../../../plugins/spaces/common/constants'; -import { getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; - -interface CreateTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; -} - -interface CreateCustomTest extends CreateTest { - type: string; - description: string; - requestBody: any; -} - -interface CreateTests { - spaceAware: CreateTest; - notSpaceAware: CreateTest; - hiddenType: CreateTest; - custom?: CreateCustomTest; +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 { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; + +export interface CreateTestDefinition extends TestDefinition { + request: { type: string; id: string }; + overwrite: boolean; } - -interface CreateTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - tests: CreateTests; +export type CreateTestSuite = TestSuite; +export interface CreateTestCase extends TestCase { + failure?: 400 | 403 | 409; } -const spaceAwareType = 'visualization'; -const notSpaceAwareType = 'globaltype'; +const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake +const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; + +// ID intentionally left blank on NEW_SINGLE_NAMESPACE_OBJ to ensure we can create saved objects without specifying the ID +// we could create six separate test cases to test every permutation, but there's no real value in doing so +const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: '' }); +const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); +const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }); +export const TEST_CASES = Object.freeze({ + ...CASES, + NEW_SINGLE_NAMESPACE_OBJ, + NEW_MULTI_NAMESPACE_OBJ, + NEW_NAMESPACE_AGNOSTIC_OBJ, +}); export function createTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { - const createExpectRbacForbidden = (type: string) => (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to create ${type}`, - }); - }; - - const expectBadRequestForHiddenType = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - message: "Unsupported saved object type: 'hiddentype': Bad Request", - statusCode: 400, - error: 'Bad Request', - }); - }; - - const createExpectSpaceAwareResults = (spaceId = DEFAULT_SPACE_ID) => async (resp: { - [key: string]: any; - }) => { - expect(resp.body) - .to.have.property('id') - .match(/^[0-9a-f-]{36}$/); - - // loose ISO8601 UTC time with milliseconds validation - expect(resp.body) - .to.have.property('updated_at') - .match(/^[\d-]{10}T[\d:\.]{12}Z$/); - - expect(resp.body).to.eql({ - id: resp.body.id, - migrationVersion: resp.body.migrationVersion, - type: spaceAwareType, - updated_at: resp.body.updated_at, - version: resp.body.version, - attributes: { - title: 'My favorite vis', - }, - references: [], - }); - - const expectedSpacePrefix = spaceId === DEFAULT_SPACE_ID ? '' : `${spaceId}:`; - - // query ES directory to ensure namespace was or wasn't specified - const { _source } = await es.get({ - id: `${expectedSpacePrefix}${spaceAwareType}:${resp.body.id}`, - index: '.kibana', - }); - - const { namespace: actualNamespace } = _source; - - if (spaceId === DEFAULT_SPACE_ID) { - expect(actualNamespace).to.eql(undefined); + const expectForbidden = expectResponses.forbidden('create'); + const expectResponseBody = ( + testCase: CreateTestCase, + spaceId = SPACES.DEFAULT.spaceId + ): ExpectResponseBody => async (response: Record) => { + if (testCase.failure === 403) { + await expectForbidden(testCase.type)(response); } else { - expect(actualNamespace).to.eql(spaceId); + // permitted + const object = response.body; + await expectResponses.permitted(object, testCase); + if (!testCase.failure) { + expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + await expectResponses.successCreated(es, spaceId, object.type, object.id); + } } }; - - const expectNotSpaceAwareRbacForbidden = createExpectRbacForbidden(notSpaceAwareType); - - const expectNotSpaceAwareResults = async (resp: { [key: string]: any }) => { - expect(resp.body) - .to.have.property('id') - .match(/^[0-9a-f-]{36}$/); - - // loose ISO8601 UTC time with milliseconds validation - expect(resp.body) - .to.have.property('updated_at') - .match(/^[\d-]{10}T[\d:\.]{12}Z$/); - - expect(resp.body).to.eql({ - id: resp.body.id, - type: notSpaceAwareType, - updated_at: resp.body.updated_at, - version: resp.body.version, - attributes: { - name: `Can't be contained to a space`, - }, - references: [], - }); - - // query ES directory to ensure namespace wasn't specified - const { _source } = await es.get({ - id: `${notSpaceAwareType}:${resp.body.id}`, - index: '.kibana', - }); - - const { namespace: actualNamespace } = _source; - - expect(actualNamespace).to.eql(undefined); + const createTestDefinitions = ( + testCases: CreateTestCase | CreateTestCase[], + forbidden: boolean, + overwrite: boolean, + options?: { + spaceId?: string; + responseBodyOverride?: ExpectResponseBody; + } + ): CreateTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + if (forbidden) { + // override the expected result in each test case + cases = cases.map(x => ({ ...x, failure: 403 })); + } + return cases.map(x => ({ + title: getTestTitle(x), + responseStatusCode: x.failure ?? 200, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x, options?.spaceId), + overwrite, + })); }; - const expectSpaceAwareRbacForbidden = createExpectRbacForbidden(spaceAwareType); - - const expectHiddenTypeRbacForbidden = createExpectRbacForbidden('hiddentype'); - - const makeCreateTest = (describeFn: DescribeFn) => ( + const makeCreateTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: CreateTestDefinition + definition: CreateTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, tests } = definition; + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; + describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`should return ${tests.spaceAware.statusCode} for a space-aware type`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/${spaceAwareType}`) - .auth(user.username, user.password) - .send({ - attributes: { - title: 'My favorite vis', - }, - }) - .expect(tests.spaceAware.statusCode) - .then(tests.spaceAware.response); - }); - - it(`should return ${tests.notSpaceAware.statusCode} for a non space-aware type`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/${notSpaceAwareType}`) - .auth(user.username, user.password) - .send({ - attributes: { - name: `Can't be contained to a space`, - }, - }) - .expect(tests.notSpaceAware.statusCode) - .then(tests.notSpaceAware.response); - }); - - it(`should return ${tests.hiddenType.statusCode} for the hiddentype`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/hiddentype`) - .auth(user.username, user.password) - .send({ - attributes: { - name: `Can't be created via the Saved Objects API`, - }, - }) - .expect(tests.hiddenType.statusCode) - .then(tests.hiddenType.response); - }); - if (tests.custom) { - it(tests.custom.description, async () => { + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const { type, id } = test.request; + const path = `${type}${id ? `/${id}` : ''}`; + const requestBody = { attributes: { [NEW_ATTRIBUTE_KEY]: NEW_ATTRIBUTE_VAL } }; + const query = test.overwrite ? '?overwrite=true' : ''; await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/${tests.custom!.type}`) - .auth(user.username, user.password) - .send(tests.custom!.requestBody) - .expect(tests.custom!.statusCode) - .then(tests.custom!.response); + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/${path}${query}`) + .auth(user?.username, user?.password) + .send(requestBody) + .expect(test.responseStatusCode) + .then(test.responseBody); }); } }); }; - const createTest = makeCreateTest(describe); + const addTests = makeCreateTest(describe); // @ts-ignore - createTest.only = makeCreateTest(describe.only); + addTests.only = makeCreateTest(describe.only); return { - createExpectSpaceAwareResults, - createTest, - expectNotSpaceAwareRbacForbidden, - expectNotSpaceAwareResults, - expectSpaceAwareRbacForbidden, - expectBadRequestForHiddenType, - expectHiddenTypeRbacForbidden, + addTests, + createTestDefinitions, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/delete.ts b/x-pack/test/saved_object_api_integration/common/suites/delete.ts index d96ae5446d7326..2222aa0c97267b 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/delete.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/delete.ts @@ -4,147 +4,97 @@ * 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 '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; - -interface DeleteTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; +import expect from '@kbn/expect/expect.js'; +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 { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; + +export interface DeleteTestDefinition extends TestDefinition { + request: { type: string; id: string }; } - -interface DeleteTests { - spaceAware: DeleteTest; - notSpaceAware: DeleteTest; - hiddenType: DeleteTest; - invalidId: DeleteTest; +export type DeleteTestSuite = TestSuite; +export interface DeleteTestCase extends TestCase { + failure?: 403 | 404; } -interface DeleteTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - otherSpaceId?: string; - tests: DeleteTests; -} +const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); +export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); export function deleteTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const createExpectNotFound = (spaceId: string, type: string, id: string) => (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: `Saved object [${type}/${getIdPrefix(spaceId)}${id}] not found`, - }); - }; - - const createExpectRbacForbidden = (type: string) => (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to delete ${type}`, - }); - }; - - const expectGenericNotFound = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: `Not Found`, - }); - }; - - const createExpectSpaceAwareNotFound = (spaceId: string = DEFAULT_SPACE_ID) => (resp: { - [key: string]: any; - }) => { - createExpectNotFound(spaceId, 'dashboard', 'be3733a0-9efe-11e7-acb3-3dab96693fab')(resp); - }; - - const createExpectUnknownDocNotFound = (spaceId: string = DEFAULT_SPACE_ID) => (resp: { - [key: string]: any; - }) => { - createExpectNotFound(spaceId, 'dashboard', `not-a-real-id`)(resp); + const expectForbidden = expectResponses.forbidden('delete'); + const expectResponseBody = (testCase: DeleteTestCase): ExpectResponseBody => async ( + response: Record + ) => { + if (testCase.failure === 403) { + await expectForbidden(testCase.type)(response); + } else { + // permitted + const object = response.body; + if (testCase.failure) { + await expectResponses.permitted(object, testCase); + } else { + // the success response for `delete` is an empty object + expect(object).to.eql({}); + } + } }; - - const expectEmpty = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({}); + const createTestDefinitions = ( + testCases: DeleteTestCase | DeleteTestCase[], + forbidden: boolean, + options?: { + spaceId?: string; + responseBodyOverride?: ExpectResponseBody; + } + ): DeleteTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + if (forbidden) { + // override the expected result in each test case + cases = cases.map(x => ({ ...x, failure: 403 })); + } + return cases.map(x => ({ + title: getTestTitle(x), + responseStatusCode: x.failure ?? 200, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x), + })); }; - const expectRbacInvalidIdForbidden = createExpectRbacForbidden('dashboard'); - - const expectRbacNotSpaceAwareForbidden = createExpectRbacForbidden('globaltype'); - - const expectRbacSpaceAwareForbidden = createExpectRbacForbidden('dashboard'); - - const expectRbacHiddenTypeForbidden = createExpectRbacForbidden('hiddentype'); - - const makeDeleteTest = (describeFn: DescribeFn) => ( + const makeDeleteTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: DeleteTestDefinition + definition: DeleteTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, otherSpaceId, tests } = definition; + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`should return ${tests.spaceAware.statusCode} when deleting a space-aware doc`, async () => - await supertest - .delete( - `${getUrlPrefix(spaceId)}/api/saved_objects/dashboard/${getIdPrefix( - otherSpaceId || spaceId - )}be3733a0-9efe-11e7-acb3-3dab96693fab` - ) - .auth(user.username, user.password) - .expect(tests.spaceAware.statusCode) - .then(tests.spaceAware.response)); - - it(`should return ${tests.notSpaceAware.statusCode} when deleting a non-space-aware doc`, async () => - await supertest - .delete( - `${getUrlPrefix( - spaceId - )}/api/saved_objects/globaltype/8121a00-8efd-21e7-1cb3-34ab966434445` - ) - .auth(user.username, user.password) - .expect(tests.notSpaceAware.statusCode) - .then(tests.notSpaceAware.response)); - - it(`should return ${tests.hiddenType.statusCode} when deleting a hiddentype doc`, async () => - await supertest - .delete(`${getUrlPrefix(spaceId)}/api/saved_objects/hiddentype/hiddentype_1`) - .auth(user.username, user.password) - .expect(tests.hiddenType.statusCode) - .then(tests.hiddenType.response)); - - it(`should return ${tests.invalidId.statusCode} when deleting an unknown doc`, async () => - await supertest - .delete( - `${getUrlPrefix(spaceId)}/api/saved_objects/dashboard/${getIdPrefix( - otherSpaceId || spaceId - )}not-a-real-id` - ) - .auth(user.username, user.password) - .expect(tests.invalidId.statusCode) - .then(tests.invalidId.response)); + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const { type, id } = test.request; + await supertest + .delete(`${getUrlPrefix(spaceId)}/api/saved_objects/${type}/${id}`) + .auth(user?.username, user?.password) + .expect(test.responseStatusCode) + .then(test.responseBody); + }); + } }); }; - const deleteTest = makeDeleteTest(describe); + const addTests = makeDeleteTest(describe); // @ts-ignore - deleteTest.only = makeDeleteTest(describe.only); + addTests.only = makeDeleteTest(describe.only); return { - expectGenericNotFound, - createExpectSpaceAwareNotFound, - createExpectUnknownDocNotFound, - deleteTest, - expectEmpty, - expectRbacInvalidIdForbidden, - expectRbacNotSpaceAwareForbidden, - expectRbacSpaceAwareForbidden, - expectRbacHiddenTypeForbidden, + addTests, + createTestDefinitions, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/export.ts b/x-pack/test/saved_object_api_integration/common/suites/export.ts index e6853096962ec2..ddd43d42410ae4 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/export.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/export.ts @@ -5,164 +5,191 @@ */ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; +import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; +import { SPACES } from '../lib/spaces'; +import { expectResponses, getUrlPrefix } from '../lib/saved_object_test_utils'; +import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; -interface ExportTest { - statusCode: number; - description: string; - response: (resp: { [key: string]: any }) => void; -} +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; -interface ExportTests { - spaceAwareType: ExportTest; - hiddenType: ExportTest; - noTypeOrObjects: ExportTest; +export interface ExportTestDefinition extends TestDefinition { + request: ReturnType; } - -interface ExportTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - tests: ExportTests; +export type ExportTestSuite = TestSuite; +export interface ExportTestCase { + title: string; + type: string; + id?: string; + successResult?: TestCase | TestCase[]; + failure?: 400 | 403; } +export const getTestCases = (spaceId?: string) => ({ + singleNamespaceObject: { + title: 'single-namespace object', + ...(spaceId === SPACE_1_ID + ? CASES.SINGLE_NAMESPACE_SPACE_1 + : spaceId === SPACE_2_ID + ? CASES.SINGLE_NAMESPACE_SPACE_2 + : CASES.SINGLE_NAMESPACE_DEFAULT_SPACE), + } as ExportTestCase, + singleNamespaceType: { + // this test explicitly ensures that single-namespace objects from other spaces are not returned + title: 'single-namespace type', + type: 'isolatedtype', + successResult: + spaceId === SPACE_1_ID + ? CASES.SINGLE_NAMESPACE_SPACE_1 + : spaceId === SPACE_2_ID + ? CASES.SINGLE_NAMESPACE_SPACE_2 + : CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + } as ExportTestCase, + multiNamespaceObject: { + title: 'multi-namespace object', + ...(spaceId === SPACE_1_ID + ? CASES.MULTI_NAMESPACE_ONLY_SPACE_1 + : spaceId === SPACE_2_ID + ? CASES.MULTI_NAMESPACE_ONLY_SPACE_2 + : CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1), + failure: 400, // multi-namespace types cannot be exported yet + } as ExportTestCase, + multiNamespaceType: { + title: 'multi-namespace type', + type: 'sharedtype', + // successResult: + // spaceId === SPACE_1_ID + // ? [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, CASES.MULTI_NAMESPACE_ONLY_SPACE_1] + // : spaceId === SPACE_2_ID + // ? CASES.MULTI_NAMESPACE_ONLY_SPACE_2 + // : CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + failure: 400, // multi-namespace types cannot be exported yet + } as ExportTestCase, + namespaceAgnosticObject: { + title: 'namespace-agnostic object', + ...CASES.NAMESPACE_AGNOSTIC, + } as ExportTestCase, + namespaceAgnosticType: { + title: 'namespace-agnostic type', + type: 'globaltype', + successResult: CASES.NAMESPACE_AGNOSTIC, + } as ExportTestCase, + hiddenObject: { title: 'hidden object', ...CASES.HIDDEN, failure: 400 } as ExportTestCase, + hiddenType: { title: 'hidden type', type: 'hiddentype', failure: 400 } as ExportTestCase, +}); +export const createRequest = ({ type, id }: ExportTestCase) => + id ? { objects: [{ type, id }] } : { type }; +const getTestTitle = ({ failure, title }: ExportTestCase) => { + let description = 'success'; + if (failure === 400) { + description = 'bad request'; + } else if (failure === 403) { + description = 'forbidden'; + } + return `${description} ["${title}"]`; +}; + export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const createExpectRbacForbidden = (type: string) => (resp: { [key: string]: any }) => { - // In export only, the API uses "bulk_get" or "find" depending on the parameters it receives. - // The best that could be done here is to have an if statement to ensure at least one of the - // two errors has been thrown. - if (resp.body.message.indexOf(`bulk_get`) !== -1) { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to bulk_get ${type}`, + const expectForbiddenBulkGet = expectResponses.forbidden('bulk_get'); + const expectForbiddenFind = expectResponses.forbidden('find'); + const expectResponseBody = (testCase: ExportTestCase): ExpectResponseBody => async ( + response: Record + ) => { + const { type, id, successResult = { type, id }, failure } = testCase; + if (failure === 403) { + // In export only, the API uses "bulk_get" or "find" depending on the parameters it receives. + // The best that could be done here is to have an if statement to ensure at least one of the + // two errors has been thrown. + if (id) { + await expectForbiddenBulkGet(type)(response); + } else { + await expectForbiddenFind(type)(response); + } + } else if (failure === 400) { + // 400 + expect(response.body.error).to.eql('Bad Request'); + expect(response.body.statusCode).to.eql(failure); + if (id) { + expect(response.body.message).to.eql( + `Trying to export object(s) with non-exportable types: ${type}:${id}` + ); + } else { + expect(response.body.message).to.eql(`Trying to export non-exportable type(s): ${type}`); + } + } else { + // 2xx + expect(response.body).not.to.have.property('error'); + const ndjson = response.text.split('\n'); + const savedObjectsArray = Array.isArray(successResult) ? successResult : [successResult]; + expect(ndjson.length).to.eql(savedObjectsArray.length + 1); + for (let i = 0; i < savedObjectsArray.length; i++) { + const object = JSON.parse(ndjson[i]); + const { type: expectedType, id: expectedId } = savedObjectsArray[i]; + expect(object.type).to.eql(expectedType); + expect(object.id).to.eql(expectedId); + expect(object.updated_at).to.match(/^[\d-]{10}T[\d:\.]{12}Z$/); + // don't test attributes, version, or references + } + const exportDetails = JSON.parse(ndjson[ndjson.length - 1]); + expect(exportDetails).to.eql({ + exportedCount: ndjson.length - 1, + missingRefCount: 0, + missingReferences: [], }); - return; } - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to find ${type}`, - }); }; - - const expectTypeOrObjectsRequired = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: '[request body]: expected a plain object value, but found [null] instead.', - }); - }; - - const expectInvalidTypeSpecified = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: `Trying to export object(s) with non-exportable types: hiddentype:hiddentype_1`, - }); - }; - - const createExpectVisualizationResults = (spaceId = DEFAULT_SPACE_ID) => (resp: { - [key: string]: any; - }) => { - const response = JSON.parse(resp.text); - expect(response).to.eql({ - type: 'visualization', - id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - version: response.version, - attributes: response.attributes, - references: [ - { - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - id: `${getIdPrefix(spaceId)}91200a00-9efd-11e7-acb3-3dab96693fab`, - }, - ], - migrationVersion: response.migrationVersion, - updated_at: '2017-09-21T18:51:23.794Z', - }); + const createTestDefinitions = ( + testCases: ExportTestCase | ExportTestCase[], + forbidden: boolean, + options?: { + responseBodyOverride?: ExpectResponseBody; + } + ): ExportTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + if (forbidden) { + // override the expected result in each test case + cases = cases.map(x => ({ ...x, failure: 403 })); + } + return cases.map(x => ({ + title: getTestTitle(x), + responseStatusCode: x.failure ?? 200, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x), + })); }; - const makeExportTest = (describeFn: DescribeFn) => ( + const makeExportTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: ExportTestDefinition + definition: ExportTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, tests } = definition; + const { user, spaceId = DEFAULT_SPACE_ID, tests } = definition; describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`space aware type should return ${tests.spaceAwareType.statusCode} with ${tests.spaceAwareType.description} when querying by type`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_export`) - .send({ - type: 'visualization', - excludeExportDetails: true, - }) - .auth(user.username, user.password) - .expect(tests.spaceAwareType.statusCode) - .then(tests.spaceAwareType.response); - }); - - it(`space aware type should return ${tests.spaceAwareType.statusCode} with ${tests.spaceAwareType.description} when querying by objects`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_export`) - .send({ - objects: [ - { - type: 'visualization', - id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - }, - ], - excludeExportDetails: true, - }) - .auth(user.username, user.password) - .expect(tests.spaceAwareType.statusCode) - .then(tests.spaceAwareType.response); - }); - - describe('hidden type', () => { - it(`should return ${tests.hiddenType.statusCode} with ${tests.hiddenType.description}`, async () => { + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { await supertest .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_export`) - .send({ - objects: [ - { - type: 'hiddentype', - id: `hiddentype_1`, - }, - ], - excludeExportDetails: true, - }) - .auth(user.username, user.password) - .expect(tests.hiddenType.statusCode) - .then(tests.hiddenType.response); + .auth(user?.username, user?.password) + .send(test.request) + .expect(test.responseStatusCode) + .then(test.responseBody); }); - }); - - describe('no type or objects', () => { - it(`should return ${tests.noTypeOrObjects.statusCode} with ${tests.noTypeOrObjects.description}`, async () => { - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_export`) - .auth(user.username, user.password) - .expect(tests.noTypeOrObjects.statusCode) - .then(tests.noTypeOrObjects.response); - }); - }); + } }); }; - const exportTest = makeExportTest(describe); + const addTests = makeExportTest(describe); // @ts-ignore - exportTest.only = makeExportTest(describe.only); + addTests.only = makeExportTest(describe.only); return { - createExpectRbacForbidden, - expectTypeOrObjectsRequired, - expectInvalidTypeSpecified, - createExpectVisualizationResults, - exportTest, + addTests, + createTestDefinitions, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/find.ts b/x-pack/test/saved_object_api_integration/common/suites/find.ts index 5479960634ccbc..75d6653365fdf0 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/find.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/find.ts @@ -3,271 +3,190 @@ * 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 '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; - -interface FindTest { - statusCode: number; - description: string; - response: (resp: { [key: string]: any }) => void; +import querystring from 'querystring'; +import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; +import { SPACES } from '../lib/spaces'; +import { expectResponses, getUrlPrefix } from '../lib/saved_object_test_utils'; +import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; + +export interface FindTestDefinition extends TestDefinition { + request: { query: string }; } - -interface FindTests { - spaceAwareType: FindTest; - notSpaceAwareType: FindTest; - unknownType: FindTest; - pageBeyondTotal: FindTest; - unknownSearchField: FindTest; - hiddenType: FindTest; - noType: FindTest; - filterWithNotSpaceAwareType: FindTest; - filterWithHiddenType: FindTest; - filterWithUnknownType: FindTest; - filterWithNoType: FindTest; - filterWithUnAllowedType: FindTest; +export type FindTestSuite = TestSuite; +export interface FindTestCase { + title: string; + query: string; + successResult?: { + savedObjects?: TestCase | TestCase[]; + page?: number; + perPage?: number; + total?: number; + }; + failure?: 400 | 403; } -interface FindTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - tests: FindTests; -} +export const getTestCases = (spaceId?: string) => ({ + singleNamespaceType: { + title: 'find single-namespace type', + query: 'type=isolatedtype&fields=title', + successResult: { + savedObjects: + spaceId === SPACE_1_ID + ? CASES.SINGLE_NAMESPACE_SPACE_1 + : spaceId === SPACE_2_ID + ? CASES.SINGLE_NAMESPACE_SPACE_2 + : CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + }, + } as FindTestCase, + multiNamespaceType: { + title: 'find multi-namespace type', + query: 'type=sharedtype&fields=title', + successResult: { + savedObjects: + spaceId === SPACE_1_ID + ? [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, CASES.MULTI_NAMESPACE_ONLY_SPACE_1] + : spaceId === SPACE_2_ID + ? CASES.MULTI_NAMESPACE_ONLY_SPACE_2 + : CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + }, + } as FindTestCase, + namespaceAgnosticType: { + title: 'find namespace-agnostic type', + query: 'type=globaltype&fields=title', + successResult: { savedObjects: CASES.NAMESPACE_AGNOSTIC }, + } as FindTestCase, + hiddenType: { title: 'find hidden type', query: 'type=hiddentype&fields=name' } as FindTestCase, + unknownType: { title: 'find unknown type', query: 'type=wigwags' } as FindTestCase, + pageBeyondTotal: { + title: 'find page beyond total', + query: 'type=isolatedtype&page=100&per_page=100', + successResult: { page: 100, perPage: 100, total: 1, savedObjects: [] }, + } as FindTestCase, + unknownSearchField: { + title: 'find unknown search field', + query: 'type=url&search_fields=a', + } as FindTestCase, + filterWithNamespaceAgnosticType: { + title: 'filter with namespace-agnostic type', + query: 'type=globaltype&filter=globaltype.attributes.title:*global*', + successResult: { savedObjects: CASES.NAMESPACE_AGNOSTIC }, + } as FindTestCase, + filterWithHiddenType: { + title: 'filter with hidden type', + query: `type=hiddentype&fields=name&filter=hiddentype.attributes.title:'hello'`, + } as FindTestCase, + filterWithUnknownType: { + title: 'filter with unknown type', + query: `type=wigwags&filter=wigwags.attributes.title:'unknown'`, + } as FindTestCase, + filterWithDisallowedType: { + title: 'filter with disallowed type', + query: `type=globaltype&filter=dashboard.title:'Requests'`, + failure: 400, + } as FindTestCase, +}); +export const createRequest = ({ query }: FindTestCase) => ({ query }); +const getTestTitle = ({ failure, title }: FindTestCase) => { + let description = 'success'; + if (failure === 400) { + description = 'bad request'; + } else if (failure === 403) { + description = 'forbidden'; + } + return `${description} ["${title}"]`; +}; export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const createExpectEmpty = (page: number, perPage: number, total: number) => (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - page, - per_page: perPage, - total, - saved_objects: [], - }); - }; - - const createExpectRbacForbidden = (type?: string) => (resp: { [key: string]: any }) => { - const message = type ? `Unable to find ${type}` : `Not authorized to find saved_object`; - - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message, - }); - }; - - const expectNotSpaceAwareResults = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 1, - saved_objects: [ - { - type: 'globaltype', - id: `8121a00-8efd-21e7-1cb3-34ab966434445`, - version: resp.body.saved_objects[0].version, - attributes: { - name: 'My favorite global object', - }, - references: [], - updated_at: '2017-09-21T18:59:16.270Z', - }, - ], - }); - }; - - const expectFilterWrongTypeError = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - error: 'Bad Request', - message: 'This type dashboard is not allowed: Bad Request', - statusCode: 400, - }); - }; - - const expectTypeRequired = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - error: 'Bad Request', - message: '[request query.type]: expected at least one defined value but got [undefined]', - statusCode: 400, - }); + const expectForbidden = expectResponses.forbidden('find'); + const expectResponseBody = (testCase: FindTestCase): ExpectResponseBody => async ( + response: Record + ) => { + const { failure, successResult = {}, query } = testCase; + const parsedQuery = querystring.parse(query); + if (failure === 403) { + const type = parsedQuery.type; + await expectForbidden(type)(response); + } else if (failure === 400) { + const type = (parsedQuery.filter as string).split('.')[0]; + expect(response.body.error).to.eql('Bad Request'); + expect(response.body.statusCode).to.eql(failure); + expect(response.body.message).to.eql(`This type ${type} is not allowed: Bad Request`); + } else { + // 2xx + expect(response.body).not.to.have.property('error'); + const { page = 1, perPage = 20, total, savedObjects = [] } = successResult; + const savedObjectsArray = Array.isArray(savedObjects) ? savedObjects : [savedObjects]; + expect(response.body.page).to.eql(page); + expect(response.body.per_page).to.eql(perPage); + expect(response.body.total).to.eql(total || savedObjectsArray.length); + for (let i = 0; i < savedObjectsArray.length; i++) { + const object = response.body.saved_objects[i]; + const { type: expectedType, id: expectedId } = savedObjectsArray[i]; + expect(object.type).to.eql(expectedType); + expect(object.id).to.eql(expectedId); + expect(object.updated_at).to.match(/^[\d-]{10}T[\d:\.]{12}Z$/); + // don't test attributes, version, or references + } + } }; - - const createExpectVisualizationResults = (spaceId = DEFAULT_SPACE_ID) => (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 1, - saved_objects: [ - { - type: 'visualization', - id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - version: resp.body.saved_objects[0].version, - attributes: { - title: 'Count of requests', - }, - migrationVersion: resp.body.saved_objects[0].migrationVersion, - references: [ - { - id: `${getIdPrefix(spaceId)}91200a00-9efd-11e7-acb3-3dab96693fab`, - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - }, - ], - updated_at: '2017-09-21T18:51:23.794Z', - }, - ], - }); + const createTestDefinitions = ( + testCases: FindTestCase | FindTestCase[], + forbidden: boolean, + options?: { + responseBodyOverride?: ExpectResponseBody; + } + ): FindTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + if (forbidden) { + // override the expected result in each test case + cases = cases.map(x => ({ ...x, failure: 403 })); + } + return cases.map(x => ({ + title: getTestTitle(x), + responseStatusCode: x.failure ?? 200, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x), + })); }; - const makeFindTest = (describeFn: DescribeFn) => ( + const makeFindTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: FindTestDefinition + definition: FindTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, tests } = definition; + const { user, spaceId = DEFAULT_SPACE_ID, tests } = definition; describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`space aware type should return ${tests.spaceAwareType.statusCode} with ${tests.spaceAwareType.description}`, async () => - await supertest - .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find?type=visualization&fields=title`) - .auth(user.username, user.password) - .expect(tests.spaceAwareType.statusCode) - .then(tests.spaceAwareType.response)); - - it(`not space aware type should return ${tests.notSpaceAwareType.statusCode} with ${tests.notSpaceAwareType.description}`, async () => - await supertest - .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find?type=globaltype&fields=name`) - .auth(user.username, user.password) - .expect(tests.notSpaceAwareType.statusCode) - .then(tests.notSpaceAwareType.response)); - - it(`finding a hiddentype should return ${tests.hiddenType.statusCode} with ${tests.hiddenType.description}`, async () => - await supertest - .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find?type=hiddentype&fields=name`) - .auth(user.username, user.password) - .expect(tests.hiddenType.statusCode) - .then(tests.hiddenType.response)); - - describe('unknown type', () => { - it(`should return ${tests.unknownType.statusCode} with ${tests.unknownType.description}`, async () => - await supertest - .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find?type=wigwags`) - .auth(user.username, user.password) - .expect(tests.unknownType.statusCode) - .then(tests.unknownType.response)); - }); - - describe('page beyond total', () => { - it(`should return ${tests.pageBeyondTotal.statusCode} with ${tests.pageBeyondTotal.description}`, async () => - await supertest - .get( - `${getUrlPrefix( - spaceId - )}/api/saved_objects/_find?type=visualization&page=100&per_page=100` - ) - .auth(user.username, user.password) - .expect(tests.pageBeyondTotal.statusCode) - .then(tests.pageBeyondTotal.response)); - }); - - describe('unknown search field', () => { - it(`should return ${tests.unknownSearchField.statusCode} with ${tests.unknownSearchField.description}`, async () => - await supertest - .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find?type=url&search_fields=a`) - .auth(user.username, user.password) - .expect(tests.unknownSearchField.statusCode) - .then(tests.unknownSearchField.response)); - }); - - describe('no type', () => { - it(`should return ${tests.noType.statusCode} with ${tests.noType.description}`, async () => - await supertest - .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find`) - .auth(user.username, user.password) - .expect(tests.noType.statusCode) - .then(tests.noType.response)); - }); - - describe('filter', () => { - it(`by wrong type should return ${tests.filterWithUnAllowedType.statusCode} with ${tests.filterWithUnAllowedType.description}`, async () => - await supertest - .get( - `${getUrlPrefix( - spaceId - )}/api/saved_objects/_find?type=globaltype&filter=dashboard.title:'Requests'` - ) - .auth(user.username, user.password) - .expect(tests.filterWithUnAllowedType.statusCode) - .then(tests.filterWithUnAllowedType.response)); - - it(`not space aware type should return ${tests.filterWithNotSpaceAwareType.statusCode} with ${tests.filterWithNotSpaceAwareType.description}`, async () => + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const query = test.request.query ? `?${test.request.query}` : ''; await supertest - .get( - `${getUrlPrefix( - spaceId - )}/api/saved_objects/_find?type=globaltype&filter=globaltype.attributes.name:*global*` - ) - .auth(user.username, user.password) - .expect(tests.filterWithNotSpaceAwareType.statusCode) - .then(tests.filterWithNotSpaceAwareType.response)); - - it(`finding a hiddentype should return ${tests.filterWithHiddenType.statusCode} with ${tests.filterWithHiddenType.description}`, async () => - await supertest - .get( - `${getUrlPrefix( - spaceId - )}/api/saved_objects/_find?type=hiddentype&fields=name&filter=hiddentype.attributes.name:'hello'` - ) - .auth(user.username, user.password) - .expect(tests.filterWithHiddenType.statusCode) - .then(tests.filterWithHiddenType.response)); - - describe('unknown type', () => { - it(`should return ${tests.filterWithUnknownType.statusCode} with ${tests.filterWithUnknownType.description}`, async () => - await supertest - .get( - `${getUrlPrefix( - spaceId - )}/api/saved_objects/_find?type=wigwags&filter=wigwags.attributes.title:'unknown'` - ) - .auth(user.username, user.password) - .expect(tests.filterWithUnknownType.statusCode) - .then(tests.filterWithUnknownType.response)); - }); - - describe('no type', () => { - it(`should return ${tests.filterWithNoType.statusCode} with ${tests.filterWithNoType.description}`, async () => - await supertest - .get( - `${getUrlPrefix( - spaceId - )}/api/saved_objects/_find?filter=global.attributes.name:*global*` - ) - .auth(user.username, user.password) - .expect(tests.filterWithNoType.statusCode) - .then(tests.filterWithNoType.response)); + .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find${query}`) + .auth(user?.username, user?.password) + .expect(test.responseStatusCode) + .then(test.responseBody); }); - }); + } }); }; - const findTest = makeFindTest(describe); + const addTests = makeFindTest(describe); // @ts-ignore - findTest.only = makeFindTest(describe.only); + addTests.only = makeFindTest(describe.only); return { - createExpectEmpty, - createExpectRbacForbidden, - createExpectVisualizationResults, - expectFilterWrongTypeError, - expectNotSpaceAwareResults, - expectTypeRequired, - findTest, + addTests, + createTestDefinitions, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/get.ts b/x-pack/test/saved_object_api_integration/common/suites/get.ts index c98209ca1e1053..d8fa4d91276d7c 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/get.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/get.ts @@ -3,193 +3,89 @@ * 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 '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; - -interface GetTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; -} - -interface GetTests { - spaceAware: GetTest; - notSpaceAware: GetTest; - hiddenType: GetTest; - doesntExist: GetTest; +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 { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; + +export interface GetTestDefinition extends TestDefinition { + request: { type: string; id: string }; } +export type GetTestSuite = TestSuite; +export type GetTestCase = TestCase; -interface GetTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - otherSpaceId?: string; - tests: GetTests; -} - -const spaceAwareId = 'dd7caf20-9efd-11e7-acb3-3dab96693fab'; -const notSpaceAwareId = '8121a00-8efd-21e7-1cb3-34ab966434445'; -const doesntExistId = 'foobar'; +const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); +export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); export function getTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const createExpectDoesntExistNotFound = (spaceId = DEFAULT_SPACE_ID) => { - return createExpectNotFound('visualization', doesntExistId, spaceId); - }; - - const createExpectNotFound = (type: string, id: string, spaceId = DEFAULT_SPACE_ID) => (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - error: 'Not Found', - message: `Saved object [${type}/${getIdPrefix(spaceId)}${id}] not found`, - statusCode: 404, - }); - }; - - const expectHiddenTypeNotFound = createExpectNotFound( - 'hiddentype', - 'hiddentype_1', - DEFAULT_SPACE_ID - ); - - const createExpectNotSpaceAwareRbacForbidden = () => (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - error: 'Forbidden', - message: `Unable to get globaltype`, - statusCode: 403, - }); - }; - - const createExpectNotSpaceAwareResults = (spaceId = DEFAULT_SPACE_ID) => (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - id: `${notSpaceAwareId}`, - type: 'globaltype', - updated_at: '2017-09-21T18:59:16.270Z', - version: resp.body.version, - attributes: { - name: 'My favorite global object', - }, - references: [], - }); - }; - - const createExpectRbacForbidden = (type: string) => (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - error: 'Forbidden', - message: `Unable to get ${type}`, - statusCode: 403, - }); + const expectForbidden = expectResponses.forbidden('get'); + const expectResponseBody = (testCase: GetTestCase): ExpectResponseBody => async ( + response: Record + ) => { + if (testCase.failure === 403) { + await expectForbidden(testCase.type)(response); + } else { + // permitted + const object = response.body; + await expectResponses.permitted(object, testCase); + } }; - - const createExpectSpaceAwareNotFound = (spaceId = DEFAULT_SPACE_ID) => { - return createExpectNotFound('visualization', spaceAwareId, spaceId); + const createTestDefinitions = ( + testCases: GetTestCase | GetTestCase[], + forbidden: boolean, + options?: { + spaceId?: string; + responseBodyOverride?: ExpectResponseBody; + } + ): GetTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + if (forbidden) { + // override the expected result in each test case + cases = cases.map(x => ({ ...x, failure: 403 })); + } + return cases.map(x => ({ + title: getTestTitle(x), + responseStatusCode: x.failure ?? 200, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x), + })); }; - const expectSpaceAwareRbacForbidden = createExpectRbacForbidden('visualization'); - const expectNotSpaceAwareRbacForbidden = createExpectRbacForbidden('globaltype'); - const expectHiddenTypeRbacForbidden = createExpectRbacForbidden('hiddentype'); - const expectDoesntExistRbacForbidden = createExpectRbacForbidden('visualization'); - - const createExpectSpaceAwareResults = (spaceId = DEFAULT_SPACE_ID) => (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, - type: 'visualization', - migrationVersion: resp.body.migrationVersion, - updated_at: '2017-09-21T18:51:23.794Z', - version: resp.body.version, - attributes: { - title: 'Count of requests', - description: '', - version: 1, - // cheat for some of the more complex attributes - visState: resp.body.attributes.visState, - uiStateJSON: resp.body.attributes.uiStateJSON, - kibanaSavedObjectMeta: resp.body.attributes.kibanaSavedObjectMeta, - }, - references: [ - { - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - id: `${getIdPrefix(spaceId)}91200a00-9efd-11e7-acb3-3dab96693fab`, - }, - ], - }); - }; - - const makeGetTest = (describeFn: DescribeFn) => ( + const makeGetTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: GetTestDefinition + definition: GetTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, otherSpaceId, tests } = definition; + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`should return ${tests.spaceAware.statusCode} when getting a space aware doc`, async () => { - await supertest - .get( - `${getUrlPrefix(spaceId)}/api/saved_objects/visualization/${getIdPrefix( - otherSpaceId || spaceId - )}${spaceAwareId}` - ) - .auth(user.username, user.password) - .expect(tests.spaceAware.statusCode) - .then(tests.spaceAware.response); - }); - - it(`should return ${tests.notSpaceAware.statusCode} when getting a non-space-aware doc`, async () => { - await supertest - .get(`${getUrlPrefix(spaceId)}/api/saved_objects/globaltype/${notSpaceAwareId}`) - .auth(user.username, user.password) - .expect(tests.notSpaceAware.statusCode) - .then(tests.notSpaceAware.response); - }); - - it(`should return ${tests.hiddenType.statusCode} when getting a hiddentype doc`, async () => { - await supertest - .get(`${getUrlPrefix(spaceId)}/api/saved_objects/hiddentype/hiddentype_1`) - .auth(user.username, user.password) - .expect(tests.hiddenType.statusCode) - .then(tests.hiddenType.response); - }); - - describe('document does not exist', () => { - it(`should return ${tests.doesntExist.statusCode}`, async () => { + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const { type, id } = test.request; await supertest - .get( - `${getUrlPrefix(spaceId)}/api/saved_objects/visualization/${getIdPrefix( - otherSpaceId || spaceId - )}${doesntExistId}` - ) - .auth(user.username, user.password) - .expect(tests.doesntExist.statusCode) - .then(tests.doesntExist.response); + .get(`${getUrlPrefix(spaceId)}/api/saved_objects/${type}/${id}`) + .auth(user?.username, user?.password) + .expect(test.responseStatusCode) + .then(test.responseBody); }); - }); + } }); }; - const getTest = makeGetTest(describe); + const addTests = makeGetTest(describe); // @ts-ignore - getTest.only = makeGetTest(describe.only); + addTests.only = makeGetTest(describe.only); return { - createExpectDoesntExistNotFound, - createExpectNotSpaceAwareRbacForbidden, - createExpectNotSpaceAwareResults, - createExpectSpaceAwareNotFound, - createExpectSpaceAwareResults, - expectHiddenTypeNotFound, - expectSpaceAwareRbacForbidden, - expectNotSpaceAwareRbacForbidden, - expectDoesntExistRbacForbidden, - expectHiddenTypeRbacForbidden, - getTest, + addTests, + createTestDefinitions, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/import.ts b/x-pack/test/saved_object_api_integration/common/suites/import.ts index f6723c912f82ee..2f631221c6955f 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/import.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/import.ts @@ -6,195 +6,152 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; - -interface ImportTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; +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 { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; + +export interface ImportTestDefinition extends TestDefinition { + request: Array<{ type: string; id: string }>; } - -interface ImportTests { - default: ImportTest; - hiddenType: ImportTest; - unknownType: ImportTest; +export type ImportTestSuite = TestSuite; +export interface ImportTestCase extends TestCase { + failure?: 400 | 409; // only used for permitted response case } -interface ImportTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - tests: ImportTests; -} +const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake +const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; -const createImportData = (spaceId: string) => [ - { - type: 'dashboard', - id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, - attributes: { - title: 'A great new dashboard', - }, - }, - { - type: 'globaltype', - id: '05976c65-1145-4858-bbf0-d225cc78a06e', - attributes: { - name: 'A new globaltype object', - }, - }, -]; +const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: 'new-dashboard-id' }); +const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); +const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }); +export const TEST_CASES = Object.freeze({ + ...CASES, + NEW_SINGLE_NAMESPACE_OBJ, + NEW_MULTI_NAMESPACE_OBJ, + NEW_NAMESPACE_AGNOSTIC_OBJ, +}); export function importTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { - const createExpectResults = (spaceId = DEFAULT_SPACE_ID) => async (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - success: true, - successCount: 2, - }); - }; - - const expectResultsWithUnsupportedHiddenType = async (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - success: false, - successCount: 2, - errors: [ - { - error: { - type: 'unsupported_type', - }, - id: '1', - type: 'hiddentype', - }, - ], - }); + const expectForbidden = expectResponses.forbidden('bulk_create'); + const expectResponseBody = ( + testCases: ImportTestCase | ImportTestCase[], + statusCode: 200 | 403, + spaceId = SPACES.DEFAULT.spaceId + ): ExpectResponseBody => async (response: Record) => { + const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; + if (statusCode === 403) { + const types = testCaseArray.map(x => x.type); + await expectForbidden(types)(response); + } else { + // permitted + const { success, successCount, errors } = response.body; + const expectedSuccesses = testCaseArray.filter(x => !x.failure); + const expectedFailures = testCaseArray.filter(x => x.failure); + expect(success).to.eql(expectedFailures.length === 0); + expect(successCount).to.eql(expectedSuccesses.length); + if (expectedFailures.length) { + expect(errors).to.have.length(expectedFailures.length); + } else { + expect(response.body).not.to.have.property('errors'); + } + for (let i = 0; i < expectedSuccesses.length; i++) { + const { type, id } = expectedSuccesses[i]; + const { _source } = await expectResponses.successCreated(es, spaceId, type, id); + expect(_source[type][NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + } + for (let i = 0; i < expectedFailures.length; i++) { + const { type, id, failure } = expectedFailures[i]; + // we don't know the order of the returned errors; search for each one + const object = (errors as Array>).find( + x => x.type === type && x.id === id + ); + expect(object).not.to.be(undefined); + if (failure === 400) { + expect(object!.error).to.eql({ type: 'unsupported_type' }); + } else { + // 409 + expect(object!.error).to.eql({ type: 'conflict' }); + } + } + } }; - - const expectUnknownTypeUnsupported = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - success: false, - successCount: 2, - errors: [ - { - id: '1', - type: 'wigwags', - title: 'Wigwags title', - error: { - type: 'unsupported_type', - }, - }, - ], - }); + const createTestDefinitions = ( + testCases: ImportTestCase | ImportTestCase[], + forbidden: boolean, + options?: { + spaceId?: string; + singleRequest?: boolean; + responseBodyOverride?: ExpectResponseBody; + } + ): ImportTestDefinition[] => { + const cases = Array.isArray(testCases) ? testCases : [testCases]; + const responseStatusCode = forbidden ? 403 : 200; + if (!options?.singleRequest) { + // if we are testing cases that should result in a forbidden response, we can do each case individually + // this ensures that multiple test cases of a single type will each result in a forbidden error + return cases.map(x => ({ + title: getTestTitle(x, responseStatusCode), + request: [createRequest(x)], + responseStatusCode, + responseBody: + options?.responseBodyOverride || + expectResponseBody(x, responseStatusCode, options?.spaceId), + })); + } + // batch into a single request to save time during test execution + return [ + { + title: getTestTitle(cases, responseStatusCode), + request: cases.map(x => createRequest(x)), + responseStatusCode, + responseBody: + options?.responseBodyOverride || + expectResponseBody(cases, responseStatusCode, options?.spaceId), + }, + ]; }; - const expectHiddenTypeUnsupported = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - success: false, - successCount: 2, - errors: [ - { - id: '1', - type: 'hiddentype', - error: { - type: 'unsupported_type', - }, - }, - ], - }); - }; - - const expectRbacForbidden = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to bulk_create dashboard,globaltype`, - }); - }; - - const makeImportTest = (describeFn: DescribeFn) => ( + const makeImportTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: ImportTestDefinition + definition: ImportTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, tests } = definition; + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`should return ${tests.default.statusCode}`, async () => { - const data = createImportData(spaceId); - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_import`) - .auth(user.username, user.password) - .attach( - 'file', - Buffer.from(data.map(obj => JSON.stringify(obj)).join('\n'), 'utf8'), - 'export.ndjson' - ) - .expect(tests.default.statusCode) - .then(tests.default.response); - }); - - describe('hiddentype', () => { - it(`should return ${tests.hiddenType.statusCode}`, async () => { - const data = createImportData(spaceId); - data.push({ - type: 'hiddentype', - id: '1', - attributes: { - name: 'My Hidden Type', - }, - }); - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_import`) - .query({ overwrite: true }) - .auth(user.username, user.password) - .attach( - 'file', - Buffer.from(data.map(obj => JSON.stringify(obj)).join('\n'), 'utf8'), - 'export.ndjson' - ) - .expect(tests.hiddenType.statusCode) - .then(tests.hiddenType.response); - }); - }); + const attrs = { attributes: { [NEW_ATTRIBUTE_KEY]: NEW_ATTRIBUTE_VAL } }; - describe('unknown type', () => { - it(`should return ${tests.unknownType.statusCode}`, async () => { - const data = createImportData(spaceId); - data.push({ - type: 'wigwags', - id: '1', - attributes: { - title: 'Wigwags title', - }, - }); + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const requestBody = test.request + .map(obj => JSON.stringify({ ...obj, ...attrs })) + .join('\n'); await supertest .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_import`) - .query({ overwrite: true }) - .auth(user.username, user.password) - .attach( - 'file', - Buffer.from(data.map(obj => JSON.stringify(obj)).join('\n'), 'utf8'), - 'export.ndjson' - ) - .expect(tests.unknownType.statusCode) - .then(tests.unknownType.response); + .auth(user?.username, user?.password) + .attach('file', Buffer.from(requestBody, 'utf8'), 'export.ndjson') + .expect(test.responseStatusCode) + .then(test.responseBody); }); - }); + } }); }; - const importTest = makeImportTest(describe); + const addTests = makeImportTest(describe); // @ts-ignore - importTest.only = makeImportTest(describe.only); + addTests.only = makeImportTest(describe.only); return { - importTest, - createExpectResults, - expectResultsWithUnsupportedHiddenType, - expectRbacForbidden, - expectUnknownTypeUnsupported, - expectHiddenTypeUnsupported, + addTests, + createTestDefinitions, + expectForbidden, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts index 1b538b9b1b65d9..47c4babc5fcf9e 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts @@ -6,219 +6,165 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; +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 { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; -interface ResolveImportErrorsTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; +export interface ResolveImportErrorsTestDefinition extends TestDefinition { + request: Array<{ type: string; id: string }>; + overwrite: boolean; } - -interface ResolveImportErrorsTests { - default: ResolveImportErrorsTest; - hiddenType: ResolveImportErrorsTest; - unknownType: ResolveImportErrorsTest; +export type ResolveImportErrorsTestSuite = TestSuite; +export interface ResolveImportErrorsTestCase extends TestCase { + failure?: 400 | 409; // only used for permitted response case } -interface ResolveImportErrorsTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - tests: ResolveImportErrorsTests; -} +const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake +const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; -const createImportData = (spaceId: string) => [ - { - type: 'dashboard', - id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, - attributes: { - title: 'A great new dashboard', - }, - }, - { - type: 'globaltype', - id: '05976c65-1145-4858-bbf0-d225cc78a06e', - attributes: { - name: 'A new globaltype object', - }, - }, -]; +const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: 'new-dashboard-id' }); +const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); +const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }); +export const TEST_CASES = Object.freeze({ + ...CASES, + NEW_SINGLE_NAMESPACE_OBJ, + NEW_MULTI_NAMESPACE_OBJ, + NEW_NAMESPACE_AGNOSTIC_OBJ, +}); export function resolveImportErrorsTestSuiteFactory( es: any, esArchiver: any, supertest: SuperTest ) { - const createExpectResults = (spaceId = DEFAULT_SPACE_ID) => async (resp: { - [key: string]: any; - }) => { - expect(resp.body).to.eql({ - success: true, - successCount: 1, - }); - }; - - const expectUnknownTypeUnsupported = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - success: false, - successCount: 1, - errors: [ - { - id: '1', - type: 'wigwags', - title: 'Wigwags title', - error: { - type: 'unsupported_type', - }, - }, - ], - }); - }; - - const expectHiddenTypeUnsupported = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - success: false, - successCount: 1, - errors: [ - { - id: '1', - type: 'hiddentype', - error: { - type: 'unsupported_type', - }, - }, - ], - }); + const expectForbidden = expectResponses.forbidden('bulk_create'); + const expectResponseBody = ( + testCases: ResolveImportErrorsTestCase | ResolveImportErrorsTestCase[], + statusCode: 200 | 403, + spaceId = SPACES.DEFAULT.spaceId + ): ExpectResponseBody => async (response: Record) => { + const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; + if (statusCode === 403) { + const types = testCaseArray.map(x => x.type); + await expectForbidden(types)(response); + } else { + // permitted + const { success, successCount, errors } = response.body; + const expectedSuccesses = testCaseArray.filter(x => !x.failure); + const expectedFailures = testCaseArray.filter(x => x.failure); + expect(success).to.eql(expectedFailures.length === 0); + expect(successCount).to.eql(expectedSuccesses.length); + if (expectedFailures.length) { + expect(errors).to.have.length(expectedFailures.length); + } else { + expect(response.body).not.to.have.property('errors'); + } + for (let i = 0; i < expectedSuccesses.length; i++) { + const { type, id } = expectedSuccesses[i]; + const { _source } = await expectResponses.successCreated(es, spaceId, type, id); + expect(_source[type][NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + } + for (let i = 0; i < expectedFailures.length; i++) { + const { type, id, failure } = expectedFailures[i]; + // we don't know the order of the returned errors; search for each one + const object = (errors as Array>).find( + x => x.type === type && x.id === id + ); + expect(object).not.to.be(undefined); + if (failure === 400) { + expect(object!.error).to.eql({ type: 'unsupported_type' }); + } else { + // 409 + expect(object!.error).to.eql({ type: 'conflict' }); + } + } + } }; - - const expectRbacForbidden = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to bulk_create dashboard`, - }); + const createTestDefinitions = ( + testCases: ResolveImportErrorsTestCase | ResolveImportErrorsTestCase[], + forbidden: boolean, + overwrite: boolean, + options?: { + spaceId?: string; + singleRequest?: boolean; + responseBodyOverride?: ExpectResponseBody; + } + ): ResolveImportErrorsTestDefinition[] => { + const cases = Array.isArray(testCases) ? testCases : [testCases]; + const responseStatusCode = forbidden ? 403 : 200; + if (!options?.singleRequest) { + // if we are testing cases that should result in a forbidden response, we can do each case individually + // this ensures that multiple test cases of a single type will each result in a forbidden error + return cases.map(x => ({ + title: getTestTitle(x, responseStatusCode), + request: [createRequest(x)], + responseStatusCode, + responseBody: + options?.responseBodyOverride || + expectResponseBody(x, responseStatusCode, options?.spaceId), + overwrite, + })); + } + // batch into a single request to save time during test execution + return [ + { + title: getTestTitle(cases, responseStatusCode), + request: cases.map(x => createRequest(x)), + responseStatusCode, + responseBody: + options?.responseBodyOverride || + expectResponseBody(cases, responseStatusCode, options?.spaceId), + overwrite, + }, + ]; }; - const makeResolveImportErrorsTest = (describeFn: DescribeFn) => ( + const makeResolveImportErrorsTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: ResolveImportErrorsTestDefinition + definition: ResolveImportErrorsTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, tests } = definition; + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; describeFn(description, () => { before(() => esArchiver.load('saved_objects/spaces')); after(() => esArchiver.unload('saved_objects/spaces')); - it(`should return ${tests.default.statusCode}`, async () => { - const data = createImportData(spaceId); - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_resolve_import_errors`) - .auth(user.username, user.password) - .field( - 'retries', - JSON.stringify([ - { - type: 'dashboard', - id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, - overwrite: true, - }, - ]) - ) - .attach( - 'file', - Buffer.from(data.map(obj => JSON.stringify(obj)).join('\n'), 'utf8'), - 'export.ndjson' - ) - .expect(tests.default.statusCode) - .then(tests.default.response); - }); + const attrs = { attributes: { [NEW_ATTRIBUTE_KEY]: NEW_ATTRIBUTE_VAL } }; - describe('unknown type', () => { - it(`should return ${tests.unknownType.statusCode}`, async () => { - const data = createImportData(spaceId); - data.push({ - type: 'wigwags', - id: '1', - attributes: { - title: 'Wigwags title', - }, - }); - await supertest - .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_resolve_import_errors`) - .auth(user.username, user.password) - .field( - 'retries', - JSON.stringify([ - { - type: 'wigwags', - id: '1', - overwrite: true, - }, - { - type: 'dashboard', - id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, - overwrite: true, - }, - ]) - ) - .attach( - 'file', - Buffer.from(data.map(obj => JSON.stringify(obj)).join('\n'), 'utf8'), - 'export.ndjson' - ) - .expect(tests.unknownType.statusCode) - .then(tests.unknownType.response); - }); - }); - describe('hidden type', () => { - it(`should return ${tests.hiddenType.statusCode}`, async () => { - const data = createImportData(spaceId); - data.push({ - type: 'hiddentype', - id: '1', - attributes: { - name: 'My Hidden Type', - }, - }); + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const retryAttrs = test.overwrite ? { overwrite: true } : {}; + const retries = JSON.stringify( + test.request.map(({ type, id }) => ({ type, id, ...retryAttrs })) + ); + const requestBody = test.request + .map(obj => JSON.stringify({ ...obj, ...attrs })) + .join('\n'); await supertest .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_resolve_import_errors`) - .auth(user.username, user.password) - .field( - 'retries', - JSON.stringify([ - { - type: 'hiddentype', - id: '1', - overwrite: true, - }, - { - type: 'dashboard', - id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, - overwrite: true, - }, - ]) - ) - .attach( - 'file', - Buffer.from(data.map(obj => JSON.stringify(obj)).join('\n'), 'utf8'), - 'export.ndjson' - ) - .expect(tests.hiddenType.statusCode) - .then(tests.hiddenType.response); + .auth(user?.username, user?.password) + .field('retries', retries) + .attach('file', Buffer.from(requestBody, 'utf8'), 'export.ndjson') + .expect(test.responseStatusCode) + .then(test.responseBody); }); - }); + } }); }; - const resolveImportErrorsTest = makeResolveImportErrorsTest(describe); + const addTests = makeResolveImportErrorsTest(describe); // @ts-ignore - resolveImportErrorsTest.only = makeResolveImportErrorsTest(describe.only); + addTests.only = makeResolveImportErrorsTest(describe.only); return { - resolveImportErrorsTest, - createExpectResults, - expectRbacForbidden, - expectUnknownTypeUnsupported, - expectHiddenTypeUnsupported, + addTests, + createTestDefinitions, + expectForbidden, }; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/update.ts b/x-pack/test/saved_object_api_integration/common/suites/update.ts index d6b7602c0114ab..587e8cf320a4f6 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/update.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/update.ts @@ -6,205 +6,97 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; -import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; -import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; -import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; - -interface UpdateTest { - statusCode: number; - response: (resp: { [key: string]: any }) => void; -} - -interface UpdateTests { - spaceAware: UpdateTest; - notSpaceAware: UpdateTest; - hiddenType: UpdateTest; - doesntExist: UpdateTest; +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 { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; + +export interface UpdateTestDefinition extends TestDefinition { + request: { type: string; id: string }; } - -interface UpdateTestDefinition { - user?: TestDefinitionAuthentication; - spaceId?: string; - otherSpaceId?: string; - tests: UpdateTests; +export type UpdateTestSuite = TestSuite; +export interface UpdateTestCase extends TestCase { + failure?: 403 | 404; } -export function updateTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const createExpectNotFound = (type: string, id: string, spaceId = DEFAULT_SPACE_ID) => (resp: { - [key: string]: any; - }) => { - expect(resp.body).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 = (type: string) => (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to update ${type}`, - }); - }; +const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake +const NEW_ATTRIBUTE_VAL = `Updated attribute value ${Date.now()}`; - const expectDoesntExistRbacForbidden = createExpectRbacForbidden('visualization'); +const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); +export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); - const expectNotSpaceAwareRbacForbidden = createExpectRbacForbidden('globaltype'); - - const expectHiddenTypeRbacForbidden = createExpectRbacForbidden('hiddentype'); - - const expectNotSpaceAwareResults = (resp: { [key: string]: any }) => { - // loose uuid validation - expect(resp.body) - .to.have.property('id') - .match(/^[0-9a-f-]{36}$/); - - // loose ISO8601 UTC time with milliseconds validation - expect(resp.body) - .to.have.property('updated_at') - .match(/^[\d-]{10}T[\d:\.]{12}Z$/); - - expect(resp.body).to.eql({ - id: resp.body.id, - type: 'globaltype', - updated_at: resp.body.updated_at, - version: resp.body.version, - attributes: { - name: 'My second favorite', - }, - }); +export function updateTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const expectForbidden = expectResponses.forbidden('update'); + const expectResponseBody = (testCase: UpdateTestCase): ExpectResponseBody => async ( + response: Record + ) => { + if (testCase.failure === 403) { + await expectForbidden(testCase.type)(response); + } else { + // permitted + const object = response.body; + await expectResponses.permitted(object, testCase); + if (!testCase.failure) { + expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); + } + } }; - - const expectSpaceAwareRbacForbidden = createExpectRbacForbidden('visualization'); - - const expectSpaceAwareResults = (resp: { [key: string]: any }) => { - // loose uuid validation ignoring prefix - expect(resp.body) - .to.have.property('id') - .match(/[0-9a-f-]{36}$/); - - // loose ISO8601 UTC time with milliseconds validation - expect(resp.body) - .to.have.property('updated_at') - .match(/^[\d-]{10}T[\d:\.]{12}Z$/); - - expect(resp.body).to.eql({ - id: resp.body.id, - type: 'visualization', - updated_at: resp.body.updated_at, - version: resp.body.version, - attributes: { - title: 'My second favorite vis', - }, - }); + const createTestDefinitions = ( + testCases: UpdateTestCase | UpdateTestCase[], + forbidden: boolean, + options?: { + responseBodyOverride?: ExpectResponseBody; + } + ): UpdateTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + if (forbidden) { + // override the expected result in each test case + cases = cases.map(x => ({ ...x, failure: 403 })); + } + return cases.map(x => ({ + title: getTestTitle(x), + responseStatusCode: x.failure ?? 200, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x), + })); }; - const makeUpdateTest = (describeFn: DescribeFn) => ( + const makeUpdateTest = (describeFn: Mocha.SuiteFunction) => ( description: string, - definition: UpdateTestDefinition + definition: UpdateTestSuite ) => { - const { user = {}, spaceId = DEFAULT_SPACE_ID, otherSpaceId, tests } = definition; + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; 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/visualization/${getIdPrefix( - otherSpaceId || spaceId - )}dd7caf20-9efd-11e7-acb3-3dab96693fab` - ) - .auth(user.username, user.password) - .send({ - attributes: { - title: 'My second favorite vis', - }, - }) - .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/globaltype/8121a00-8efd-21e7-1cb3-34ab966434445` - ) - .auth(user.username, user.password) - .send({ - attributes: { - name: 'My second favorite', - }, - }) - .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/hiddentype/hiddentype_1`) - .auth(user.username, user.password) - .send({ - attributes: { - name: 'My favorite hidden type', - }, - }) - .expect(tests.hiddenType.statusCode) - .then(tests.hiddenType.response); - }); - describe('unknown id', () => { - it(`should return ${tests.doesntExist.statusCode}`, async () => { + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const { type, id } = test.request; + const requestBody = { attributes: { [NEW_ATTRIBUTE_KEY]: NEW_ATTRIBUTE_VAL } }; await supertest - .put( - `${getUrlPrefix(spaceId)}/api/saved_objects/visualization/${getIdPrefix( - spaceId - )}not an id` - ) - .auth(user.username, user.password) - .send({ - attributes: { - title: 'My second favorite vis', - }, - }) - .expect(tests.doesntExist.statusCode) - .then(tests.doesntExist.response); + .put(`${getUrlPrefix(spaceId)}/api/saved_objects/${type}/${id}`) + .auth(user?.username, user?.password) + .send(requestBody) + .expect(test.responseStatusCode) + .then(test.responseBody); }); - }); + } }); }; - const updateTest = makeUpdateTest(describe); + const addTests = makeUpdateTest(describe); // @ts-ignore - updateTest.only = makeUpdateTest(describe.only); + addTests.only = makeUpdateTest(describe.only); return { - createExpectDoesntExistNotFound, - createExpectSpaceAwareNotFound, - expectSpaceNotFound: expectHiddenTypeNotFound, - expectDoesntExistRbacForbidden, - expectNotSpaceAwareRbacForbidden, - expectNotSpaceAwareResults, - expectSpaceAwareRbacForbidden, - expectSpaceAwareResults, - expectHiddenTypeRbacForbidden, - updateTest, + addTests, + createTestDefinitions, }; } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts index 7768665f3b941a..70d3afbfc9af37 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts @@ -4,206 +4,104 @@ * 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 { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { bulkCreateTestSuiteFactory } from '../../common/suites/bulk_create'; +import { + bulkCreateTestSuiteFactory, + TEST_CASES as CASES, + BulkCreateTestDefinition, +} from '../../common/suites/bulk_create'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = (overwrite: boolean, spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { + ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_MULTI_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); const es = getService('legacyEs'); - const { - bulkCreateTest, - createExpectResults, - createExpectRbacForbidden, - expectBadRequestForHiddenType, - expectedForbiddenTypesWithHiddenType, - } = bulkCreateTestSuiteFactory(es, esArchiver, supertest); + const { addTests, createTestDefinitions, expectForbidden } = bulkCreateTestSuiteFactory( + es, + esArchiver, + supertest + ); + const createTests = (overwrite: boolean, spaceId: string) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite, spaceId); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: createTestDefinitions(allTypes, true, overwrite, { spaceId }), + authorized: [ + createTestDefinitions(normalTypes, false, overwrite, { spaceId, singleRequest: true }), + createTestDefinitions(hiddenType, true, overwrite, { spaceId }), + createTestDefinitions(allTypes, true, overwrite, { + spaceId, + singleRequest: true, + responseBodyOverride: expectForbidden(['hiddentype']), + }), + ].flat(), + superuser: createTestDefinitions(allTypes, false, overwrite, { + spaceId, + singleRequest: true, + }), + }; + }; describe('_bulk_create', () => { - [ - { - 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 => { - bulkCreateTest(`user with no access within the ${scenario.spaceId} space`, { - user: scenario.users.noAccess, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`superuser within the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingSpace: { - statusCode: 200, - response: expectBadRequestForHiddenType, - }, - }, - }); - - bulkCreateTest(`legacy user within the ${scenario.spaceId} space`, { - user: scenario.users.legacyAll, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkCreateTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkCreateTest(`rbac user with read globally within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkCreateTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); + getTestScenarios([false, true]).securityAndSpaces.forEach( + ({ spaceId, users, modifier: overwrite }) => { + const suffix = ` within the ${spaceId} space${overwrite ? ' with overwrite enabled' : ''}`; + const { unauthorized, authorized, superuser } = createTests(overwrite!, spaceId); + const _addTests = (user: TestUser, tests: BulkCreateTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - bulkCreateTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtOtherSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - }); + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally, users.allAtSpace].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); + } + ); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts index ec5bce17075697..09ea867bff3715 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts @@ -4,205 +4,91 @@ * 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 { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { bulkGetTestSuiteFactory } from '../../common/suites/bulk_get'; +import { + bulkGetTestSuiteFactory, + TEST_CASES as CASES, + BulkGetTestDefinition, +} from '../../common/suites/bulk_get'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { ...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.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - const { - bulkGetTest, - createExpectResults, - createExpectRbacForbidden, - expectBadRequestForHiddenType, - expectedForbiddenTypesWithHiddenType, - } = bulkGetTestSuiteFactory(esArchiver, supertest); + const { addTests, createTestDefinitions, expectForbidden } = bulkGetTestSuiteFactory( + esArchiver, + supertest + ); + const createTests = (spaceId: string) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false, { singleRequest: true }), + createTestDefinitions(hiddenType, true), + createTestDefinitions(allTypes, true, { + singleRequest: true, + responseBodyOverride: expectForbidden(['hiddentype']), + }), + ].flat(), + superuser: createTestDefinitions(allTypes, false, { singleRequest: true }), + }; + }; describe('_bulk_get', () => { - [ - { - 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 => { - bulkGetTest(`user with no access within the ${scenario.spaceId} space`, { - user: scenario.users.noAccess, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkGetTest(`superuser within the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingHiddenType: { - statusCode: 200, - response: expectBadRequestForHiddenType, - }, - }, - }); - - bulkGetTest(`legacy user within the ${scenario.spaceId} space`, { - user: scenario.users.legacyAll, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkGetTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized, superuser } = createTests(spaceId); + const _addTests = (user: TestUser, tests: BulkGetTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - bulkGetTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, + [users.noAccess, users.legacyAll, users.allAtOtherSpace].forEach(user => { + _addTests(user, unauthorized); }); - - bulkGetTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkGetTest(`rbac user with read globally within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkGetTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkGetTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkGetTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtOtherSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, + [ + users.dualAll, + users.dualRead, + users.allGlobally, + users.readGlobally, + users.allAtSpace, + users.readAtSpace, + ].forEach(user => { + _addTests(user, authorized); }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts index 06240647b37a83..987209653b347a 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts @@ -4,290 +4,91 @@ * 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 { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { bulkUpdateTestSuiteFactory } from '../../common/suites/bulk_update'; +import { + bulkUpdateTestSuiteFactory, + TEST_CASES as CASES, + 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 = (spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { ...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.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; 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), - }, - }, - }); + const { addTests, createTestDefinitions, expectForbidden } = bulkUpdateTestSuiteFactory( + esArchiver, + supertest + ); + const createTests = (spaceId: string) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false, { singleRequest: true }), + createTestDefinitions(hiddenType, true), + createTestDefinitions(allTypes, true, { + singleRequest: true, + responseBodyOverride: expectForbidden(['hiddentype']), + }), + ].flat(), + superuser: createTestDefinitions(allTypes, false, { singleRequest: true }), + }; + }; - 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, - }, - }, - }); + describe('_bulk_update', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized, superuser } = createTests(spaceId); + const _addTests = (user: TestUser, tests: BulkUpdateTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - 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), - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach(user => { + _addTests(user, unauthorized); }); - - 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, - }, - }, + [users.dualAll, users.allGlobally, users.allAtSpace].forEach(user => { + _addTests(user, authorized); }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts index e4adaa580c1dbe..7278504b8f0e83 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts @@ -4,248 +4,91 @@ * 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 { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { createTestSuiteFactory } from '../../common/suites/create'; +import { + createTestSuiteFactory, + TEST_CASES as CASES, + CreateTestDefinition, +} from '../../common/suites/create'; -export default function({ getService }: FtrProviderContext) { - const supertestWithoutAuth = getService('supertestWithoutAuth'); - const es = getService('legacyEs'); - const esArchiver = getService('esArchiver'); - - const { - createTest, - createExpectSpaceAwareResults, - expectNotSpaceAwareResults, - expectNotSpaceAwareRbacForbidden, - expectSpaceAwareRbacForbidden, - expectBadRequestForHiddenType, - expectHiddenTypeRbacForbidden, - } = createTestSuiteFactory(es, esArchiver, supertestWithoutAuth); - - describe('create', () => { - [ - { - 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 => { - createTest(`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, - }, - }, - }); - - createTest(`superuser within the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 400, - response: expectBadRequestForHiddenType, - }, - }, - }); +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail409 } = testCaseFailures; - createTest(`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, - }, - }, - }); +const createTestCases = (overwrite: boolean, spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { + ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_MULTI_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; - createTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); - - createTest(`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, - }, - }, - }); - - createTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); - - createTest(`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, - }, - }, - }); +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + const es = getService('legacyEs'); - createTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); + const { addTests, createTestDefinitions } = createTestSuiteFactory(es, esArchiver, supertest); + const createTests = (overwrite: boolean, spaceId: string) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite, spaceId); + return { + unauthorized: createTestDefinitions(allTypes, true, overwrite, { spaceId }), + authorized: [ + createTestDefinitions(normalTypes, false, overwrite, { spaceId }), + createTestDefinitions(hiddenType, true, overwrite, { spaceId }), + ].flat(), + superuser: createTestDefinitions(allTypes, false, overwrite, { spaceId }), + }; + }; - createTest(`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, - }, - }, - }); + describe('_create', () => { + getTestScenarios([false, true]).securityAndSpaces.forEach( + ({ spaceId, users, modifier: overwrite }) => { + const suffix = ` within the ${spaceId} space${overwrite ? ' with overwrite enabled' : ''}`; + const { unauthorized, authorized, superuser } = createTests(overwrite!, spaceId); + const _addTests = (user: TestUser, tests: CreateTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - createTest(`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, - }, - }, - }); - }); + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally, users.allAtSpace].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); + } + ); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts index bfd2112428db49..995b8fc2422d93 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts @@ -4,288 +4,83 @@ * 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 { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { deleteTestSuiteFactory } from '../../common/suites/delete'; +import { + deleteTestSuiteFactory, + TEST_CASES as CASES, + DeleteTestDefinition, +} from '../../common/suites/delete'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { ...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.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - describe('delete', () => { - const { - createExpectUnknownDocNotFound, - deleteTest, - expectEmpty, - expectRbacSpaceAwareForbidden, - expectRbacNotSpaceAwareForbidden, - expectRbacInvalidIdForbidden, - expectGenericNotFound, - expectRbacHiddenTypeForbidden, - } = deleteTestSuiteFactory(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 => { - deleteTest(`user with no access within the ${scenario.spaceId} space`, { - user: scenario.users.noAccess, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacHiddenTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`superuser within the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectEmpty, - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 404, - response: expectGenericNotFound, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(scenario.spaceId), - }, - }, - }); - - deleteTest(`legacy user within the ${scenario.spaceId} space`, { - user: scenario.users.legacyAll, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacHiddenTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectEmpty, - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 403, - response: expectRbacHiddenTypeForbidden, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(scenario.spaceId), - }, - }, - }); + const { addTests, createTestDefinitions } = deleteTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); + return { + unauthorized: createTestDefinitions(allTypes, true, { spaceId }), + authorized: [ + createTestDefinitions(normalTypes, false, { spaceId }), + createTestDefinitions(hiddenType, true, { spaceId }), + ].flat(), + superuser: createTestDefinitions(allTypes, false, { spaceId }), + }; + }; - deleteTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacHiddenTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); + describe('_delete', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized, superuser } = createTests(spaceId); + const _addTests = (user: TestUser, tests: DeleteTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - deleteTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectEmpty, - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 403, - response: expectRbacHiddenTypeForbidden, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(scenario.spaceId), - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach(user => { + _addTests(user, unauthorized); }); - - deleteTest(`rbac user with read globally within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacHiddenTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectEmpty, - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 403, - response: expectRbacHiddenTypeForbidden, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(scenario.spaceId), - }, - }, - }); - - deleteTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacHiddenTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtOtherSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacHiddenTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, + [users.dualAll, users.allGlobally, users.allAtSpace].forEach(user => { + _addTests(user, authorized); }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts index b64c3ed87c35da..6f2426e55c6a6e 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts @@ -4,274 +4,70 @@ * 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 { getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { exportTestSuiteFactory } from '../../common/suites/export'; +import { + exportTestSuiteFactory, + getTestCases, + ExportTestDefinition, +} from '../../common/suites/export'; + +const createTestCases = (spaceId: string) => { + const cases = getTestCases(spaceId); + const exportableTypes = [ + cases.singleNamespaceObject, + cases.singleNamespaceType, + cases.namespaceAgnosticObject, + cases.namespaceAgnosticType, + ]; + const nonExportableTypes = [ + cases.multiNamespaceObject, + cases.multiNamespaceType, + cases.hiddenObject, + cases.hiddenType, + ]; + const allTypes = exportableTypes.concat(nonExportableTypes); + return { exportableTypes, nonExportableTypes, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - describe('export', () => { - const { - createExpectRbacForbidden, - expectTypeOrObjectsRequired, - createExpectVisualizationResults, - expectInvalidTypeSpecified, - exportTest, - } = exportTestSuiteFactory(esArchiver, supertest); + const { addTests, createTestDefinitions } = exportTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const { exportableTypes, nonExportableTypes, allTypes } = createTestCases(spaceId); + return { + unauthorized: [ + createTestDefinitions(exportableTypes, true), + createTestDefinitions(nonExportableTypes, false), + ].flat(), + authorized: createTestDefinitions(allTypes, false), + }; + }; - [ - { - spaceId: SPACES.DEFAULT.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - }, - }, - { - spaceId: SPACES.SPACE_1.spaceId, - users: { - noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, - superuser: AUTHENTICATION.SUPERUSER, - legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, - allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, - readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - }, - }, - ].forEach(scenario => { - exportTest(`user with no access within the ${scenario.spaceId} space`, { - user: scenario.users.noAccess, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); + describe('_export', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized } = createTests(spaceId); + const _addTests = (user: TestUser, tests: ExportTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - exportTest(`superuser with the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, + [users.noAccess, users.legacyAll, users.allAtOtherSpace].forEach(user => { + _addTests(user, unauthorized); }); - - exportTest(`legacy user within the ${scenario.spaceId} space`, { - user: scenario.users.legacyAll, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest(`rbac user with read globally within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest(`rbac user with all at the other space within ${scenario.spaceId} space`, { - user: scenario.users.allAtOtherSpace, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, + [ + users.dualAll, + users.dualRead, + users.allGlobally, + users.readGlobally, + users.allAtSpace, + users.readAtSpace, + users.superuser, + ].forEach(user => { + _addTests(user, authorized); }); }); }); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts index 366b8b44585cdb..7c16c01d203c05 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts @@ -4,727 +4,71 @@ * 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 { getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { findTestSuiteFactory } from '../../common/suites/find'; +import { findTestSuiteFactory, getTestCases, FindTestDefinition } from '../../common/suites/find'; + +const createTestCases = (spaceId: string) => { + const cases = getTestCases(spaceId); + const normalTypes = [ + cases.singleNamespaceType, + cases.multiNamespaceType, + cases.namespaceAgnosticType, + cases.pageBeyondTotal, + cases.unknownSearchField, + cases.filterWithNamespaceAgnosticType, + cases.filterWithDisallowedType, + ]; + const hiddenAndUnknownTypes = [ + cases.hiddenType, + cases.unknownType, + cases.filterWithHiddenType, + cases.filterWithUnknownType, + ]; + const allTypes = normalTypes.concat(hiddenAndUnknownTypes); + return { normalTypes, hiddenAndUnknownTypes, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - describe('find', () => { - const { - createExpectEmpty, - createExpectRbacForbidden, - createExpectVisualizationResults, - expectFilterWrongTypeError, - expectNotSpaceAwareResults, - expectTypeRequired, - findTest, - } = findTestSuiteFactory(esArchiver, supertest); + const { addTests, createTestDefinitions } = findTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const { normalTypes, hiddenAndUnknownTypes, allTypes } = createTestCases(spaceId); + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenAndUnknownTypes, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; - [ - { - 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 => { - findTest(`user with no access within the ${scenario.spaceId} space`, { - user: scenario.users.noAccess, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - notSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and find url message', - statusCode: 403, - response: createExpectRbacForbidden('url'), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'forbidden', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - }, - }); + describe('_find', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized, superuser } = createTests(spaceId); + const _addTests = (user: TestUser, tests: FindTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - findTest(`superuser within the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - unknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - filterWithUnknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, + [users.noAccess, users.legacyAll, users.allAtOtherSpace].forEach(user => { + _addTests(user, unauthorized); }); - - findTest(`legacy user within the ${scenario.spaceId} space`, { - user: scenario.users.legacyAll, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - notSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and find url message', - statusCode: 403, - response: createExpectRbacForbidden('url'), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'forbidden', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - }, - }); - - findTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`rbac user with read globally within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(scenario.spaceId), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtOtherSpace, - spaceId: scenario.spaceId, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - notSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and find url message', - statusCode: 403, - response: createExpectRbacForbidden('url'), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'forbidden', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - }, + [ + users.dualAll, + users.dualRead, + users.allGlobally, + users.readGlobally, + users.allAtSpace, + users.readAtSpace, + ].forEach(user => { + _addTests(user, authorized); }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts index 9667abcc5e57a0..9e3203e1474930 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts @@ -4,289 +4,84 @@ * 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 { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { getTestSuiteFactory } from '../../common/suites/get'; +import { + getTestSuiteFactory, + TEST_CASES as CASES, + GetTestDefinition, +} from '../../common/suites/get'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { ...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.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - const { - createExpectDoesntExistNotFound, - createExpectSpaceAwareResults, - createExpectNotSpaceAwareResults, - expectSpaceAwareRbacForbidden, - expectNotSpaceAwareRbacForbidden, - expectDoesntExistRbacForbidden, - expectHiddenTypeRbacForbidden, - expectHiddenTypeNotFound, - getTest, - } = getTestSuiteFactory(esArchiver, supertest); - - describe('get', () => { - [ - { - 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 => { - getTest(`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, - }, - }, - }); - - getTest(`superuser within the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 404, - response: expectHiddenTypeNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); - - getTest(`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, - }, - }, - }); - - getTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); + const { addTests, createTestDefinitions } = getTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenType, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; - getTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); + describe('_get', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized, superuser } = createTests(spaceId); + const _addTests = (user: TestUser, tests: GetTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - getTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, + [users.noAccess, users.legacyAll, users.allAtOtherSpace].forEach(user => { + _addTests(user, unauthorized); }); - - getTest(`rbac user with read globall within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); - - getTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); - - getTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(scenario.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); - - getTest(`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, - }, - }, + [ + users.dualAll, + users.dualRead, + users.allGlobally, + users.readGlobally, + users.allAtSpace, + users.readAtSpace, + ].forEach(user => { + _addTests(user, authorized); }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts index 58859c292ce359..10c7f61dce5cc0 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts @@ -4,245 +4,92 @@ * 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 { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { importTestSuiteFactory } from '../../common/suites/import'; +import { + importTestSuiteFactory, + TEST_CASES as CASES, + ImportTestDefinition, +} from '../../common/suites/import'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = (spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const importableTypes = [ + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(spaceId === DEFAULT_SPACE_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(spaceId === SPACE_2_ID) }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409() }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; + const nonImportableTypes = [ + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, + { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + ]; + const allTypes = importableTypes.concat(nonImportableTypes); + return { importableTypes, nonImportableTypes, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); const es = getService('legacyEs'); - const { - importTest, - createExpectResults, - expectRbacForbidden, - expectUnknownTypeUnsupported: expectUnknownTypeUnsupported, - expectHiddenTypeUnsupported, - } = importTestSuiteFactory(es, esArchiver, supertest); + const { addTests, createTestDefinitions, expectForbidden } = importTestSuiteFactory( + es, + esArchiver, + supertest + ); + const createTests = (spaceId: string) => { + const { importableTypes, nonImportableTypes, allTypes } = createTestCases(spaceId); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: [ + createTestDefinitions(importableTypes, true, { spaceId }), + createTestDefinitions(nonImportableTypes, false, { spaceId, singleRequest: true }), + createTestDefinitions(allTypes, true, { + spaceId, + singleRequest: true, + responseBodyOverride: expectForbidden(['dashboard', 'globaltype', 'isolatedtype']), + }), + ].flat(), + authorized: createTestDefinitions(allTypes, false, { spaceId, singleRequest: true }), + }; + }; describe('_import', () => { - [ - { - 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 => { - importTest(`user with no access within the ${scenario.spaceId} space`, { - user: scenario.users.noAccess, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`superuser within the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - importTest(`legacy user within the ${scenario.spaceId} space`, { - user: scenario.users.legacyAll, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized } = createTests(spaceId); + const _addTests = (user: TestUser, tests: ImportTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - importTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach(user => { + _addTests(user, unauthorized); }); - - importTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - importTest(`rbac user with read globally within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - importTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { - user: scenario.users.allAtOtherSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, + [users.dualAll, users.allGlobally, users.allAtSpace, users.superuser].forEach(user => { + _addTests(user, authorized); }); }); }); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts index bb42c5422ece51..46d7ab6425989b 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts @@ -20,6 +20,7 @@ export default function({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./bulk_create')); loadTestFile(require.resolve('./bulk_get')); + loadTestFile(require.resolve('./bulk_update')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./export')); @@ -28,6 +29,5 @@ 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')); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts index 6c91fe6310170d..8e8fe874b43178 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts @@ -4,258 +4,99 @@ * 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 { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { resolveImportErrorsTestSuiteFactory } from '../../common/suites/resolve_import_errors'; +import { + resolveImportErrorsTestSuiteFactory, + TEST_CASES as CASES, + ResolveImportErrorsTestDefinition, +} from '../../common/suites/resolve_import_errors'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = (overwrite: boolean, spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const importableTypes = [ + { + ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; + const nonImportableTypes = [ + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, + { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + ]; + const allTypes = importableTypes.concat(nonImportableTypes); + return { importableTypes, nonImportableTypes, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); const es = getService('legacyEs'); - const { - resolveImportErrorsTest, - createExpectResults, - expectRbacForbidden, - expectUnknownTypeUnsupported, - expectHiddenTypeUnsupported, - } = resolveImportErrorsTestSuiteFactory(es, esArchiver, supertest); + const { addTests, createTestDefinitions, expectForbidden } = resolveImportErrorsTestSuiteFactory( + es, + esArchiver, + supertest + ); + const createTests = (overwrite: boolean, spaceId: string) => { + const { importableTypes, nonImportableTypes, allTypes } = createTestCases(overwrite, spaceId); + const singleRequest = true; + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: [ + createTestDefinitions(importableTypes, true, overwrite, { spaceId }), + createTestDefinitions(nonImportableTypes, false, overwrite, { spaceId, singleRequest }), + createTestDefinitions(allTypes, true, overwrite, { + spaceId, + singleRequest, + responseBodyOverride: expectForbidden(['dashboard', 'globaltype', 'isolatedtype']), + }), + ].flat(), + authorized: createTestDefinitions(allTypes, false, overwrite, { spaceId, singleRequest }), + }; + }; describe('_resolve_import_errors', () => { - [ - { - 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 => { - resolveImportErrorsTest(`user with no access within the ${scenario.spaceId} space`, { - user: scenario.users.noAccess, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - resolveImportErrorsTest(`superuser within the ${scenario.spaceId} space`, { - user: scenario.users.superuser, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - resolveImportErrorsTest(`legacy user within the ${scenario.spaceId} space`, { - user: scenario.users.legacyAll, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - resolveImportErrorsTest(`dual-privileges user within the ${scenario.spaceId} space`, { - user: scenario.users.dualAll, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - resolveImportErrorsTest( - `dual-privileges readonly user within the ${scenario.spaceId} space`, - { - user: scenario.users.dualRead, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - } - ); - - resolveImportErrorsTest(`rbac user with all globally within the ${scenario.spaceId} space`, { - user: scenario.users.allGlobally, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - resolveImportErrorsTest(`rbac user with read globally within the ${scenario.spaceId} space`, { - user: scenario.users.readGlobally, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - resolveImportErrorsTest( - `rbac user with all at the space within the ${scenario.spaceId} space`, - { - user: scenario.users.allAtSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectResults(scenario.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - } - ); - - resolveImportErrorsTest( - `rbac user with read at the space within the ${scenario.spaceId} space`, - { - user: scenario.users.readAtSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - } - ); + getTestScenarios([false, true]).securityAndSpaces.forEach( + ({ spaceId, users, modifier: overwrite }) => { + const suffix = ` within the ${spaceId} space${overwrite ? ' with overwrite enabled' : ''}`; + const { unauthorized, authorized } = createTests(overwrite!, spaceId); + const _addTests = (user: TestUser, tests: ResolveImportErrorsTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - resolveImportErrorsTest( - `rbac user with all at other space within the ${scenario.spaceId} space`, - { - user: scenario.users.allAtOtherSpace, - spaceId: scenario.spaceId, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - } - ); - }); + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally, users.allAtSpace, users.superuser].forEach(user => { + _addTests(user, authorized); + }); + } + ); }); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts index 8eb06e41e2a41f..21f354d2a8e761 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts @@ -4,289 +4,83 @@ * 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 { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { updateTestSuiteFactory } from '../../common/suites/update'; +import { + updateTestSuiteFactory, + TEST_CASES as CASES, + UpdateTestDefinition, +} from '../../common/suites/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 = (spaceId: string) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { ...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.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - describe('update', () => { - const { - createExpectDoesntExistNotFound, - expectDoesntExistRbacForbidden, - expectNotSpaceAwareResults, - expectNotSpaceAwareRbacForbidden, - expectSpaceAwareRbacForbidden, - expectSpaceAwareResults, - expectSpaceNotFound, - expectHiddenTypeRbacForbidden, - updateTest, - } = updateTestSuiteFactory(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 => { - updateTest(`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, - }, - }, - }); - - updateTest(`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: 404, - response: expectSpaceNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); - - updateTest(`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, - }, - }, - }); - - updateTest(`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: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); + const { addTests, createTestDefinitions } = updateTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId); + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenType, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; - updateTest(`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, - }, - }, - }); + describe('_update', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` within the ${spaceId} space`; + const { unauthorized, authorized, superuser } = createTests(spaceId); + const _addTests = (user: TestUser, tests: UpdateTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; - updateTest(`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: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach(user => { + _addTests(user, unauthorized); }); - - updateTest(`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, - }, - }, - }); - - updateTest(`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: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(scenario.spaceId), - }, - }, - }); - - updateTest(`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, - }, - }, - }); - - updateTest(`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, - }, - }, + [users.dualAll, users.allGlobally, users.allAtSpace].forEach(user => { + _addTests(user, authorized); }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts index 943a22c4399c77..5b3397c7909aed 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts @@ -4,176 +4,88 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { bulkCreateTestSuiteFactory } from '../../common/suites/bulk_create'; +import { + bulkCreateTestSuiteFactory, + TEST_CASES as CASES, + BulkCreateTestDefinition, +} from '../../common/suites/bulk_create'; + +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = (overwrite: boolean) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite) }, + CASES.SINGLE_NAMESPACE_SPACE_1, + CASES.SINGLE_NAMESPACE_SPACE_2, + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409() }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_MULTI_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); const es = getService('legacyEs'); - const { - bulkCreateTest, - createExpectResults, - createExpectRbacForbidden, - expectBadRequestForHiddenType, - expectedForbiddenTypesWithHiddenType: expectedForbiddenTypesWithHiddenType, - } = bulkCreateTestSuiteFactory(es, esArchiver, supertest); + const { addTests, createTestDefinitions, expectForbidden } = bulkCreateTestSuiteFactory( + es, + esArchiver, + supertest + ); + const createTests = (overwrite: boolean) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: createTestDefinitions(allTypes, true, overwrite), + authorized: [ + createTestDefinitions(normalTypes, false, overwrite, { singleRequest: true }), + createTestDefinitions(hiddenType, true, overwrite), + createTestDefinitions(allTypes, true, overwrite, { + singleRequest: true, + responseBodyOverride: expectForbidden(['hiddentype']), + }), + ].flat(), + superuser: createTestDefinitions(allTypes, false, overwrite, { singleRequest: true }), + }; + }; describe('_bulk_create', () => { - bulkCreateTest(`user with no access`, { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - includingSpace: { - statusCode: 200, - response: expectBadRequestForHiddenType, - }, - }, - }); - - bulkCreateTest(`legacy user`, { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkCreateTest(`dual-privileges readonly user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`rbac user with all globally`, { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkCreateTest(`rbac readonly user`, { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`rbac user with all at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`rbac user with read at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkCreateTest(`rbac user with all at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); + getTestScenarios([false, true]).security.forEach(({ users, modifier: overwrite }) => { + const suffix = overwrite ? ' with overwrite enabled' : ''; + const { unauthorized, authorized, superuser } = createTests(overwrite!); + const _addTests = (user: TestUser, tests: BulkCreateTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, tests }); + }; - bulkCreateTest(`rbac user with read at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingSpace: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts index fde98694fe5753..69494ed254669d 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts @@ -4,175 +4,81 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { bulkGetTestSuiteFactory } from '../../common/suites/bulk_get'; +import { + bulkGetTestSuiteFactory, + TEST_CASES as CASES, + BulkGetTestDefinition, +} from '../../common/suites/bulk_get'; + +const { fail400, fail404 } = testCaseFailures; + +const createTestCases = () => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - const { - bulkGetTest, - createExpectResults, - createExpectRbacForbidden, - expectedForbiddenTypesWithHiddenType, - expectBadRequestForHiddenType, - } = bulkGetTestSuiteFactory(esArchiver, supertest); + const { addTests, createTestDefinitions, expectForbidden } = bulkGetTestSuiteFactory( + esArchiver, + supertest + ); + const createTests = () => { + const { normalTypes, hiddenType, allTypes } = createTestCases(); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false, { singleRequest: true }), + createTestDefinitions(hiddenType, true), + createTestDefinitions(allTypes, true, { + singleRequest: true, + responseBodyOverride: expectForbidden(['hiddentype']), + }), + ].flat(), + superuser: createTestDefinitions(allTypes, false, { singleRequest: true }), + }; + }; describe('_bulk_get', () => { - bulkGetTest(`user with no access`, { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkGetTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - includingHiddenType: { - statusCode: 200, - response: expectBadRequestForHiddenType, - }, - }, - }); - - bulkGetTest(`legacy user`, { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkGetTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkGetTest(`dual-privileges readonly user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkGetTest(`rbac user with all globally`, { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkGetTest(`rbac user with read globally`, { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(['hiddentype']), - }, - }, - }); - - bulkGetTest(`rbac user with all at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkGetTest(`rbac user with read at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); - - bulkGetTest(`rbac user with all at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, - }); + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized, superuser } = createTests(); + const _addTests = (user: TestUser, tests: BulkGetTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; - bulkGetTest(`rbac user with read at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - tests: { - default: { - statusCode: 403, - response: createExpectRbacForbidden(), - }, - includingHiddenType: { - statusCode: 403, - response: createExpectRbacForbidden(expectedForbiddenTypesWithHiddenType), - }, - }, + [ + users.noAccess, + users.legacyAll, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.dualRead, users.allGlobally, users.readGlobally].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts index 6f4635f17cf8cd..fb169f4c6fb863 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts @@ -4,268 +4,83 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { bulkUpdateTestSuiteFactory } from '../../common/suites/bulk_update'; +import { + bulkUpdateTestSuiteFactory, + TEST_CASES as CASES, + BulkUpdateTestDefinition, +} from '../../common/suites/bulk_update'; + +const { fail404 } = testCaseFailures; + +const createTestCases = () => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; 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(), - }, - }, - }); + const { addTests, createTestDefinitions, expectForbidden } = bulkUpdateTestSuiteFactory( + esArchiver, + supertest + ); + const createTests = () => { + const { normalTypes, hiddenType, allTypes } = createTestCases(); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false, { singleRequest: true }), + createTestDefinitions(hiddenType, true), + createTestDefinitions(allTypes, true, { + singleRequest: true, + responseBodyOverride: expectForbidden(['hiddentype']), + }), + ].flat(), + superuser: createTestDefinitions(allTypes, false, { singleRequest: true }), + }; + }; - 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, - }, - }, - }); + describe('_bulk_update', () => { + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized, superuser } = createTests(); + const _addTests = (user: TestUser, tests: BulkUpdateTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; - 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, - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts index 60a9fa0a86aa65..dc8e564e424773 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts @@ -4,222 +4,79 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { createTestSuiteFactory } from '../../common/suites/create'; +import { + createTestSuiteFactory, + TEST_CASES as CASES, + CreateTestDefinition, +} from '../../common/suites/create'; -export default function({ getService }: FtrProviderContext) { - const supertestWithoutAuth = getService('supertestWithoutAuth'); - const es = getService('legacyEs'); - const esArchiver = getService('esArchiver'); - - const { - createTest, - createExpectSpaceAwareResults, - expectNotSpaceAwareResults, - expectNotSpaceAwareRbacForbidden, - expectSpaceAwareRbacForbidden, - expectBadRequestForHiddenType, - expectHiddenTypeRbacForbidden, - } = createTestSuiteFactory(es, esArchiver, supertestWithoutAuth); - - describe('create', () => { - createTest(`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, - }, - }, - }); +const { fail400, fail409 } = testCaseFailures; - createTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 400, - response: expectBadRequestForHiddenType, - }, - }, - }); - - createTest(`legacy user`, { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); +const createTestCases = (overwrite: boolean) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite) }, + CASES.SINGLE_NAMESPACE_SPACE_1, + CASES.SINGLE_NAMESPACE_SPACE_2, + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409() }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_MULTI_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; - createTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); - - createTest(`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, - }, - }, - }); - - createTest(`rbac user with all globally`, { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - }, - }); - - createTest(`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, - }, - }, - }); - - createTest(`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, - }, - }, - }); +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + const es = getService('legacyEs'); - createTest(`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, - }, - }, - }); + const { addTests, createTestDefinitions } = createTestSuiteFactory(es, esArchiver, supertest); + const createTests = (overwrite: boolean) => { + const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite); + return { + unauthorized: createTestDefinitions(allTypes, true, overwrite), + authorized: [ + createTestDefinitions(normalTypes, false, overwrite), + createTestDefinitions(hiddenType, true, overwrite), + ].flat(), + superuser: createTestDefinitions(allTypes, false, overwrite), + }; + }; - createTest(`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, - }, - }, - }); + describe('_create', () => { + getTestScenarios([false, true]).security.forEach(({ users, modifier: overwrite }) => { + const suffix = overwrite ? ' with overwrite enabled' : ''; + const { unauthorized, authorized, superuser } = createTests(overwrite!); + const _addTests = (user: TestUser, tests: CreateTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, tests }); + }; - createTest(`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, - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts index f775b5a365d6b0..05939197be352f 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts @@ -4,266 +4,75 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { deleteTestSuiteFactory } from '../../common/suites/delete'; +import { + deleteTestSuiteFactory, + TEST_CASES as CASES, + DeleteTestDefinition, +} from '../../common/suites/delete'; + +const { fail404 } = testCaseFailures; + +const createTestCases = () => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - describe('delete', () => { - const { - createExpectUnknownDocNotFound, - deleteTest, - expectEmpty, - expectRbacSpaceAwareForbidden, - expectRbacNotSpaceAwareForbidden, - expectRbacInvalidIdForbidden, - expectRbacHiddenTypeForbidden: expectRbacSpaceTypeForbidden, - expectGenericNotFound, - } = deleteTestSuiteFactory(esArchiver, supertest); - - deleteTest(`user with no access`, { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - spaceAware: { - statusCode: 200, - response: expectEmpty, - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 404, - response: expectGenericNotFound, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(), - }, - }, - }); + const { addTests, createTestDefinitions } = deleteTestSuiteFactory(esArchiver, supertest); + const createTests = () => { + const { normalTypes, hiddenType, allTypes } = createTestCases(); + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenType, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; - deleteTest(`legacy user`, { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - spaceAware: { - statusCode: 200, - response: expectEmpty, - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(), - }, - }, - }); - - deleteTest(`dual-privileges readonly user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`rbac user with all globally`, { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - spaceAware: { - statusCode: 200, - response: expectEmpty, - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(), - }, - }, - }); - - deleteTest(`rbac user with read globally`, { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`rbac user with all at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`rbac user with read at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); - - deleteTest(`rbac user with all at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, - }); + describe('_delete', () => { + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized, superuser } = createTests(); + const _addTests = (user: TestUser, tests: DeleteTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; - deleteTest(`rbac user with readonly at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectRbacSpaceAwareForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectRbacNotSpaceAwareForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacSpaceTypeForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacInvalidIdForbidden, - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/export.ts b/x-pack/test/saved_object_api_integration/security_only/apis/export.ts index 2a2c3a9b90b08f..0fae45a1897a7d 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/export.ts @@ -4,252 +4,75 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { exportTestSuiteFactory } from '../../common/suites/export'; +import { + exportTestSuiteFactory, + getTestCases, + ExportTestDefinition, +} from '../../common/suites/export'; + +const createTestCases = () => { + const cases = getTestCases(); + const exportableTypes = [ + cases.singleNamespaceObject, + cases.singleNamespaceType, + cases.namespaceAgnosticObject, + cases.namespaceAgnosticType, + ]; + const nonExportableTypes = [ + cases.multiNamespaceObject, + cases.multiNamespaceType, + cases.hiddenObject, + cases.hiddenType, + ]; + const allTypes = exportableTypes.concat(nonExportableTypes); + return { exportableTypes, nonExportableTypes, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - describe('export', () => { - const { - createExpectRbacForbidden, - expectTypeOrObjectsRequired, - createExpectVisualizationResults, - expectInvalidTypeSpecified, - exportTest, - } = exportTestSuiteFactory(esArchiver, supertest); - - exportTest('user with no access', { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest('superuser', { - user: AUTHENTICATION.SUPERUSER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest('legacy user', { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest('dual-privileges user', { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest('dual-privileges readonly user', { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest('rbac user with all globally', { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest('rbac user with read globally', { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); + const { addTests, createTestDefinitions } = exportTestSuiteFactory(esArchiver, supertest); + const createTests = () => { + const { exportableTypes, nonExportableTypes, allTypes } = createTestCases(); + return { + unauthorized: [ + createTestDefinitions(exportableTypes, true), + createTestDefinitions(nonExportableTypes, false), + ].flat(), + authorized: createTestDefinitions(allTypes, false), + }; + }; - exportTest('rbac user with all at default space', { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest('rbac user with read at default space', { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); - - exportTest('rbac user with all at space_1', { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); + describe('_export', () => { + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized } = createTests(); + const _addTests = (user: TestUser, tests: ExportTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; - exportTest('rbac user with read at space_1', { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, + [ + users.noAccess, + users.legacyAll, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [ + users.dualAll, + users.dualRead, + users.allGlobally, + users.readGlobally, + users.superuser, + ].forEach(user => { + _addTests(user, authorized); + }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts index 64d85a199e7bca..97513783b94b94 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts @@ -4,749 +4,70 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { findTestSuiteFactory } from '../../common/suites/find'; +import { findTestSuiteFactory, getTestCases, FindTestDefinition } from '../../common/suites/find'; + +const createTestCases = () => { + const cases = getTestCases(); + const normalTypes = [ + cases.singleNamespaceType, + cases.multiNamespaceType, + cases.namespaceAgnosticType, + cases.pageBeyondTotal, + cases.unknownSearchField, + cases.filterWithNamespaceAgnosticType, + cases.filterWithDisallowedType, + ]; + const hiddenAndUnknownTypes = [ + cases.hiddenType, + cases.unknownType, + cases.filterWithHiddenType, + cases.filterWithUnknownType, + ]; + const allTypes = normalTypes.concat(hiddenAndUnknownTypes); + return { normalTypes, hiddenAndUnknownTypes, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - describe('find', () => { - const { - createExpectEmpty, - createExpectRbacForbidden, - createExpectVisualizationResults, - expectFilterWrongTypeError, - expectNotSpaceAwareResults, - expectTypeRequired, - findTest, - } = findTestSuiteFactory(esArchiver, supertest); - - findTest(`user with no access`, { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - notSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - hiddenType: { - description: 'forbidden login and find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and unknown search field', - statusCode: 403, - response: createExpectRbacForbidden('url'), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - filterWithHiddenType: { - description: 'forbidden login and find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'forbidden', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - }, - }); - - findTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - unknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - filterWithUnknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`legacy user`, { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - spaceAwareType: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - notSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - hiddenType: { - description: 'forbidden login and find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and unknown search field', - statusCode: 403, - response: createExpectRbacForbidden('url'), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'forbidden login and find globaltype message', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - filterWithHiddenType: { - description: 'forbidden login and find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'forbidden', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - }, - }); - - findTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`dual-privileges readonly user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`rbac user with all globally`, { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); - - findTest(`rbac user with read globally`, { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); + const { addTests, createTestDefinitions } = findTestSuiteFactory(esArchiver, supertest); + const createTests = () => { + const { normalTypes, hiddenAndUnknownTypes, allTypes } = createTestCases(); + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenAndUnknownTypes, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; - findTest(`rbac user with all at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and unknown search field', - statusCode: 403, - response: createExpectRbacForbidden('url'), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'forbidden', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - }, - }); - - findTest(`rbac user with read at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and unknown search field', - statusCode: 403, - response: createExpectRbacForbidden('url'), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'forbidden', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - }, - }); - - findTest(`rbac user with all at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and unknown search field', - statusCode: 403, - response: createExpectRbacForbidden('url'), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'forbidden', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - }, - }); + describe('_find', () => { + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized, superuser } = createTests(); + const _addTests = (user: TestUser, tests: FindTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; - findTest(`rbac user with read at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - notSpaceAwareType: { - description: 'only the globaltype', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - hiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and unknown search field', - statusCode: 403, - response: createExpectRbacForbidden('url'), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the globaltype', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - filterWithHiddenType: { - description: 'forbidden find hiddentype message', - statusCode: 403, - response: createExpectRbacForbidden('hiddentype'), - }, - filterWithUnknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'forbidden', - statusCode: 403, - response: createExpectRbacForbidden('globaltype'), - }, - }, + [ + users.noAccess, + users.legacyAll, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.dualRead, users.allGlobally, users.readGlobally].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/get.ts b/x-pack/test/saved_object_api_integration/security_only/apis/get.ts index 2a31463fce8b2e..7cd50fe4cea617 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/get.ts @@ -4,267 +4,73 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { getTestSuiteFactory } from '../../common/suites/get'; +import { + getTestSuiteFactory, + TEST_CASES as CASES, + GetTestDefinition, +} from '../../common/suites/get'; + +const { fail404 } = testCaseFailures; + +const createTestCases = () => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - const { - createExpectDoesntExistNotFound, - createExpectSpaceAwareResults, - createExpectNotSpaceAwareResults, - expectSpaceAwareRbacForbidden, - expectNotSpaceAwareRbacForbidden, - expectDoesntExistRbacForbidden, - expectHiddenTypeRbacForbidden, - expectHiddenTypeNotFound, - getTest, - } = getTestSuiteFactory(esArchiver, supertest); - - describe('get', () => { - getTest(`user with no access`, { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - spaceAware: { - statusCode: 403, - response: expectSpaceAwareRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - notSpaceAware: { - statusCode: 403, - response: expectNotSpaceAwareRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectDoesntExistRbacForbidden, - }, - }, - }); - - getTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(), - }, - hiddenType: { - statusCode: 404, - response: expectHiddenTypeNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(), - }, - }, - }); + const { addTests, createTestDefinitions } = getTestSuiteFactory(esArchiver, supertest); + const createTests = () => { + const { normalTypes, hiddenType, allTypes } = createTestCases(); + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenType, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; - getTest(`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, - }, - }, - }); - - getTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(), - }, - }, - }); - - getTest(`dual-privileges readonly user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(), - }, - }, - }); - - getTest(`rbac user with all globally`, { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(), - }, - }, - }); - - getTest(`rbac user with read globally`, { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(), - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(), - }, - }, - }); - - getTest(`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, - }, - }, - }); - - getTest(`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, - }, - }, - }); - - getTest(`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, - }, - }, - }); + describe('_get', () => { + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized, superuser } = createTests(); + const _addTests = (user: TestUser, tests: GetTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; - getTest(`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, - }, - }, + [ + users.noAccess, + users.legacyAll, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.dualRead, users.allGlobally, users.readGlobally].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts index 770410dcfed819..5a6e530b029391 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts @@ -4,223 +4,87 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { importTestSuiteFactory } from '../../common/suites/import'; +import { + importTestSuiteFactory, + TEST_CASES as CASES, + ImportTestDefinition, +} from '../../common/suites/import'; + +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = () => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const importableTypes = [ + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409() }, + CASES.SINGLE_NAMESPACE_SPACE_1, + CASES.SINGLE_NAMESPACE_SPACE_2, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409() }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; + const nonImportableTypes = [ + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, + { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + ]; + const allTypes = importableTypes.concat(nonImportableTypes); + return { importableTypes, nonImportableTypes, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); const es = getService('legacyEs'); - const { - importTest, - createExpectResults, - expectRbacForbidden, - expectUnknownTypeUnsupported, - expectResultsWithUnsupportedHiddenType, - } = importTestSuiteFactory(es, esArchiver, supertest); + const { addTests, createTestDefinitions, expectForbidden } = importTestSuiteFactory( + es, + esArchiver, + supertest + ); + const createTests = () => { + const { importableTypes, nonImportableTypes, allTypes } = createTestCases(); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: [ + createTestDefinitions(importableTypes, true), + createTestDefinitions(nonImportableTypes, false, { singleRequest: true }), + createTestDefinitions(allTypes, true, { + singleRequest: true, + responseBodyOverride: expectForbidden(['dashboard', 'globaltype', 'isolatedtype']), + }), + ].flat(), + authorized: createTestDefinitions(allTypes, false, { singleRequest: true }), + }; + }; describe('_import', () => { - importTest(`user with no access`, { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - hiddenType: { - // import filters out the space type, so the remaining objects will import successfully - statusCode: 200, - response: expectResultsWithUnsupportedHiddenType, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - importTest(`legacy user`, { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - hiddenType: { - // import filters out the space type, so the remaining objects will import successfully - statusCode: 200, - response: expectResultsWithUnsupportedHiddenType, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - importTest(`dual-privileges readonly user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`rbac user with all globally`, { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - hiddenType: { - // import filters out the space type, so the remaining objects will import successfully - statusCode: 200, - response: expectResultsWithUnsupportedHiddenType, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - importTest(`rbac readonly user`, { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`rbac user with all at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`rbac user with read at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - importTest(`rbac user with all at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized } = createTests(); + const _addTests = (user: TestUser, tests: ImportTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; - importTest(`rbac user with read at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally, users.superuser].forEach(user => { + _addTests(user, authorized); + }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/index.ts b/x-pack/test/saved_object_api_integration/security_only/apis/index.ts index bb637a9bc4c90e..f581e18ff17afb 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/index.ts @@ -20,6 +20,7 @@ export default function({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./bulk_create')); loadTestFile(require.resolve('./bulk_get')); + loadTestFile(require.resolve('./bulk_update')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./export')); @@ -28,6 +29,5 @@ 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')); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts index 59d50c16c259a8..f945d2b64c4324 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts @@ -4,221 +4,88 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { resolveImportErrorsTestSuiteFactory } from '../../common/suites/resolve_import_errors'; +import { + resolveImportErrorsTestSuiteFactory, + TEST_CASES as CASES, + ResolveImportErrorsTestDefinition, +} from '../../common/suites/resolve_import_errors'; + +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = (overwrite: boolean) => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const importableTypes = [ + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite) }, + CASES.SINGLE_NAMESPACE_SPACE_1, + CASES.SINGLE_NAMESPACE_SPACE_2, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; + const nonImportableTypes = [ + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, + { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + ]; + const allTypes = importableTypes.concat(nonImportableTypes); + return { importableTypes, nonImportableTypes, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); const es = getService('legacyEs'); - const { - resolveImportErrorsTest, - createExpectResults, - - expectRbacForbidden, - expectUnknownTypeUnsupported, - expectHiddenTypeUnsupported, - } = resolveImportErrorsTestSuiteFactory(es, esArchiver, supertest); + const { addTests, createTestDefinitions, expectForbidden } = resolveImportErrorsTestSuiteFactory( + es, + esArchiver, + supertest + ); + const createTests = (overwrite: boolean) => { + const { importableTypes, nonImportableTypes, allTypes } = createTestCases(overwrite); + // use singleRequest to reduce execution time and/or test combined cases + return { + unauthorized: [ + createTestDefinitions(importableTypes, true, overwrite), + createTestDefinitions(nonImportableTypes, false, overwrite, { singleRequest: true }), + createTestDefinitions(allTypes, true, overwrite, { + singleRequest: true, + responseBodyOverride: expectForbidden(['dashboard', 'globaltype', 'isolatedtype']), + }), + ].flat(), + authorized: createTestDefinitions(allTypes, false, overwrite, { singleRequest: true }), + }; + }; describe('_resolve_import_errors', () => { - resolveImportErrorsTest(`user with no access`, { - user: AUTHENTICATION.NOT_A_KIBANA_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - resolveImportErrorsTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - resolveImportErrorsTest(`legacy user`, { - user: AUTHENTICATION.KIBANA_LEGACY_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - resolveImportErrorsTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - resolveImportErrorsTest(`dual-privileges readonly user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - resolveImportErrorsTest(`rbac user with all globally`, { - user: AUTHENTICATION.KIBANA_RBAC_USER, - tests: { - default: { - statusCode: 200, - response: createExpectResults(), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - resolveImportErrorsTest(`rbac readonly user`, { - user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - resolveImportErrorsTest(`rbac user with all at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - resolveImportErrorsTest(`rbac user with read at default space`, { - user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); - - resolveImportErrorsTest(`rbac user with all at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, - }); + getTestScenarios([false, true]).security.forEach(({ users, modifier: overwrite }) => { + const suffix = overwrite ? ' with overwrite enabled' : ''; + const { unauthorized, authorized } = createTests(overwrite!); + const _addTests = (user: TestUser, tests: ResolveImportErrorsTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, tests }); + }; - resolveImportErrorsTest(`rbac user with read at space_1`, { - user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - hiddenType: { - statusCode: 403, - response: expectRbacForbidden, - }, - unknownType: { - statusCode: 403, - response: expectRbacForbidden, - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally, users.superuser].forEach(user => { + _addTests(user, authorized); + }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/update.ts b/x-pack/test/saved_object_api_integration/security_only/apis/update.ts index 8564296bdf5589..e1e3a5f8a7dc74 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/update.ts @@ -4,267 +4,75 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AUTHENTICATION } from '../../common/lib/authentication'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { updateTestSuiteFactory } from '../../common/suites/update'; +import { + updateTestSuiteFactory, + TEST_CASES as CASES, + UpdateTestDefinition, +} from '../../common/suites/update'; + +const { fail404 } = testCaseFailures; + +const createTestCases = () => { + // for each permitted (non-403) outcome, if failure !== undefined then we expect + // to receive an error; otherwise, we expect to receive a success result + const normalTypes = [ + CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, + CASES.NAMESPACE_AGNOSTIC, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, + ]; + const hiddenType = [{ ...CASES.HIDDEN, ...fail404() }]; + const allTypes = normalTypes.concat(hiddenType); + return { normalTypes, hiddenType, allTypes }; +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); - describe('update', () => { - const { - createExpectDoesntExistNotFound, - expectDoesntExistRbacForbidden, - expectNotSpaceAwareResults, - expectNotSpaceAwareRbacForbidden, - expectSpaceAwareRbacForbidden, - expectSpaceAwareResults, - expectSpaceNotFound, - expectHiddenTypeRbacForbidden, - updateTest, - } = updateTestSuiteFactory(esArchiver, supertest); - - updateTest(`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, - }, - }, - }); - - updateTest(`superuser`, { - user: AUTHENTICATION.SUPERUSER, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 404, - response: expectSpaceNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(), - }, - }, - }); + const { addTests, createTestDefinitions } = updateTestSuiteFactory(esArchiver, supertest); + const createTests = () => { + const { normalTypes, hiddenType, allTypes } = createTestCases(); + return { + unauthorized: createTestDefinitions(allTypes, true), + authorized: [ + createTestDefinitions(normalTypes, false), + createTestDefinitions(hiddenType, true), + ].flat(), + superuser: createTestDefinitions(allTypes, false), + }; + }; - updateTest(`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, - }, - }, - }); - - updateTest(`dual-privileges user`, { - user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 403, - response: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(), - }, - }, - }); - - updateTest(`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, - }, - }, - }); - - updateTest(`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: expectHiddenTypeRbacForbidden, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(), - }, - }, - }); - - updateTest(`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, - }, - }, - }); - - updateTest(`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, - }, - }, - }); - - updateTest(`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, - }, - }, - }); - - updateTest(`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, - }, - }, - }); + describe('_update', () => { + getTestScenarios().security.forEach(({ users }) => { + const { unauthorized, authorized, superuser } = createTests(); + const _addTests = (user: TestUser, tests: UpdateTestDefinition[]) => { + addTests(user.description, { user, tests }); + }; - updateTest(`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, - }, - }, + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.allAtSpace1, + users.readAtSpace1, + ].forEach(user => { + _addTests(user, unauthorized); + }); + [users.dualAll, users.allGlobally].forEach(user => { + _addTests(user, authorized); + }); + _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts index 690e358b744d54..70d74822a8b0f6 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts @@ -6,83 +6,72 @@ import expect from '@kbn/expect'; import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { bulkCreateTestSuiteFactory } from '../../common/suites/bulk_create'; +import { bulkCreateTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/bulk_create'; -const expectNamespaceSpecifiedBadRequest = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - error: 'Bad Request', - message: '[request body.0.namespace]: definition for this key is missing', - statusCode: 400, - }); -}; +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = (overwrite: boolean, 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, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + { ...CASES.HIDDEN, ...fail400() }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_MULTI_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, +]; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); const es = getService('legacyEs'); - const { - bulkCreateTest, - createExpectResults, - expectBadRequestForHiddenType, - } = bulkCreateTestSuiteFactory(es, esArchiver, supertest); - - describe('_bulk_create', () => { - bulkCreateTest('in the current space (space_1)', { - ...SPACES.SPACE_1, - tests: { - default: { - statusCode: 200, - response: createExpectResults(SPACES.SPACE_1.spaceId), - }, - includingSpace: { - statusCode: 200, - response: expectBadRequestForHiddenType, - }, - custom: { - description: 'when a namespace is specified on the saved object', - requestBody: [ - { - type: 'visualization', - namespace: 'space_1', - attributes: { - title: 'something', - }, - }, - ], - statusCode: 400, - response: expectNamespaceSpecifiedBadRequest, + const { addTests, createTestDefinitions } = bulkCreateTestSuiteFactory(es, esArchiver, supertest); + const createTests = (overwrite: boolean, spaceId: string) => { + const testCases = createTestCases(overwrite, spaceId); + return createTestDefinitions(testCases, false, overwrite, { + spaceId, + singleRequest: true, + }).concat( + ['namespace', 'namespaces'].map(key => ({ + title: `(bad request) when ${key} is specified on the saved object`, + request: [{ type: 'isolatedtype', id: 'some-id', [key]: 'any-value' }] as any, + responseStatusCode: 400, + responseBody: async (response: Record) => { + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: `[request body.0.${key}]: definition for this key is missing`, + }); }, - }, - }); + overwrite, + })) + ); + }; - bulkCreateTest('in the default space', { - ...SPACES.DEFAULT, - tests: { - default: { - statusCode: 200, - response: createExpectResults(SPACES.DEFAULT.spaceId), - }, - includingSpace: { - statusCode: 200, - response: expectBadRequestForHiddenType, - }, - custom: { - description: 'when a namespace is specified on the saved object', - requestBody: [ - { - type: 'visualization', - namespace: 'space_1', - attributes: { - title: 'something', - }, - }, - ], - statusCode: 400, - response: expectNamespaceSpecifiedBadRequest, - }, - }, + describe('_bulk_create', () => { + getTestScenarios([false, true]).spaces.forEach(({ spaceId, modifier: overwrite }) => { + const suffix = overwrite ? ' with overwrite enabled' : ''; + const tests = createTests(overwrite!, spaceId); + addTests(`within the ${spaceId} space${suffix}`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts index bed29f8eb39a14..ad107197505859 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts @@ -5,48 +5,48 @@ */ import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { bulkGetTestSuiteFactory } from '../../common/suites/bulk_get'; +import { bulkGetTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/bulk_get'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail404 } = testCaseFailures; + +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, ...fail400() }, + { ...CASES.DOES_NOT_EXIST, ...fail404() }, +]; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const { - bulkGetTest, - createExpectResults, - createExpectNotFoundResults, - expectBadRequestForHiddenType, - } = bulkGetTestSuiteFactory(esArchiver, supertest); + const { addTests, createTestDefinitions } = bulkGetTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false, { singleRequest: true }); + }; describe('_bulk_get', () => { - bulkGetTest(`objects within the current space (space_1)`, { - ...SPACES.SPACE_1, - tests: { - default: { - statusCode: 200, - response: createExpectResults(SPACES.SPACE_1.spaceId), - }, - includingHiddenType: { - statusCode: 200, - response: expectBadRequestForHiddenType, - }, - }, - }); - - bulkGetTest(`objects within another space`, { - ...SPACES.SPACE_1, - otherSpaceId: SPACES.SPACE_2.spaceId, - tests: { - default: { - statusCode: 200, - response: createExpectNotFoundResults(SPACES.SPACE_2.spaceId), - }, - includingHiddenType: { - statusCode: 200, - response: expectBadRequestForHiddenType, - }, - }, + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts index 0681908a765cea..be6ce9e30c56ec 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts @@ -5,88 +5,48 @@ */ import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { bulkUpdateTestSuiteFactory } from '../../common/suites/bulk_update'; +import { bulkUpdateTestSuiteFactory, TEST_CASES as CASES } 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 = (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() }, +]; 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), - }, - }, - }); + const { addTests, createTestDefinitions } = bulkUpdateTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false, { singleRequest: true }); + }; - 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(), - }, - }, + describe('_bulk_update', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts index 3bd4019649363f..d0c6a21e739719 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts @@ -4,90 +4,56 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { createTestSuiteFactory } from '../../common/suites/create'; +import { createTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/create'; -const expectNamespaceSpecifiedBadRequest = (resp: { [key: string]: any }) => { - expect(resp.body).to.eql({ - error: 'Bad Request', - message: '[request body.namespace]: definition for this key is missing', - statusCode: 400, - }); -}; +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = (overwrite: boolean, 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, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + { ...CASES.HIDDEN, ...fail400() }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + CASES.NEW_MULTI_NAMESPACE_OBJ, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, +]; export default function({ getService }: FtrProviderContext) { - const supertestWithoutAuth = getService('supertestWithoutAuth'); - const es = getService('legacyEs'); + const supertest = getService('supertestWithoutAuth'); const esArchiver = getService('esArchiver'); + const es = getService('legacyEs'); - const { - createTest, - createExpectSpaceAwareResults, - expectNotSpaceAwareResults, - expectBadRequestForHiddenType, - } = createTestSuiteFactory(es, esArchiver, supertestWithoutAuth); - - describe('create', () => { - createTest('in the current space (space_1)', { - ...SPACES.SPACE_1, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(SPACES.SPACE_1.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 400, - response: expectBadRequestForHiddenType, - }, - custom: { - description: 'when a namespace is specified on the saved object', - type: 'visualization', - requestBody: { - namespace: 'space_1', - attributes: { - title: 'something', - }, - }, - statusCode: 400, - response: expectNamespaceSpecifiedBadRequest, - }, - }, - }); + const { addTests, createTestDefinitions } = createTestSuiteFactory(es, esArchiver, supertest); + const createTests = (overwrite: boolean, spaceId: string) => { + const testCases = createTestCases(overwrite, spaceId); + return createTestDefinitions(testCases, false, overwrite, { spaceId }); + }; - createTest('in the default space', { - ...SPACES.DEFAULT, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(SPACES.DEFAULT.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 400, - response: expectBadRequestForHiddenType, - }, - custom: { - description: 'when a namespace is specified on the saved object', - type: 'visualization', - requestBody: { - namespace: 'space_1', - attributes: { - title: 'something', - }, - }, - statusCode: 400, - response: expectNamespaceSpecifiedBadRequest, - }, - }, + describe('_create', () => { + getTestScenarios([false, true]).spaces.forEach(({ spaceId, modifier: overwrite }) => { + const suffix = overwrite ? ' with overwrite enabled' : ''; + const tests = createTests(overwrite!, spaceId); + addTests(`within the ${spaceId} space${suffix}`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts index 437b3f95024c61..bb48e06d93a09e 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts @@ -5,87 +5,48 @@ */ import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { deleteTestSuiteFactory } from '../../common/suites/delete'; +import { deleteTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/delete'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +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() }, +]; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - describe('delete', () => { - const { - createExpectSpaceAwareNotFound, - createExpectUnknownDocNotFound, - deleteTest, - expectEmpty, - expectGenericNotFound, - } = deleteTestSuiteFactory(esArchiver, supertest); - - deleteTest(`in the default space`, { - ...SPACES.DEFAULT, - tests: { - spaceAware: { - statusCode: 200, - response: expectEmpty, - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 404, - response: expectGenericNotFound, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(SPACES.DEFAULT.spaceId), - }, - }, - }); - - deleteTest(`in the current space (space_1)`, { - ...SPACES.SPACE_1, - tests: { - spaceAware: { - statusCode: 200, - response: expectEmpty, - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 404, - response: expectGenericNotFound, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(SPACES.SPACE_1.spaceId), - }, - }, - }); + const { addTests, createTestDefinitions } = deleteTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false, { spaceId }); + }; - deleteTest(`in another space (space_2)`, { - spaceId: SPACES.SPACE_1.spaceId, - otherSpaceId: SPACES.SPACE_2.spaceId, - tests: { - spaceAware: { - statusCode: 404, - response: createExpectSpaceAwareNotFound(SPACES.SPACE_2.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: expectEmpty, - }, - hiddenType: { - statusCode: 404, - response: expectGenericNotFound, - }, - invalidId: { - statusCode: 404, - response: createExpectUnknownDocNotFound(SPACES.SPACE_2.spaceId), - }, - }, + describe('_delete', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/export.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/export.ts index f4be02c05ac88d..25d4fbfae990b2 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/export.ts @@ -4,62 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SPACES } from '../../common/lib/spaces'; +import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { exportTestSuiteFactory } from '../../common/suites/export'; +import { exportTestSuiteFactory, getTestCases } from '../../common/suites/export'; + +const createTestCases = (spaceId: string) => { + const cases = getTestCases(spaceId); + return Object.values(cases); +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const { - expectTypeOrObjectsRequired, - createExpectVisualizationResults, - expectInvalidTypeSpecified, - exportTest, - } = exportTestSuiteFactory(esArchiver, supertest); - - describe('export', () => { - exportTest('objects only within the current space (space_1)', { - ...SPACES.SPACE_1, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(SPACES.SPACE_1.spaceId), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, - }); + const { addTests, createTestDefinitions } = exportTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false); + }; - exportTest('objects only within the current space (default)', { - ...SPACES.DEFAULT, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(SPACES.DEFAULT.spaceId), - }, - hiddenType: { - description: 'exporting space not allowed', - statusCode: 400, - response: expectInvalidTypeSpecified, - }, - noTypeOrObjects: { - description: 'bad request, type or object is required', - statusCode: 400, - response: expectTypeOrObjectsRequired, - }, - }, + describe('_export', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts index a07d3edf834e9d..a15f7de404db83 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts @@ -4,154 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SPACES } from '../../common/lib/spaces'; +import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { findTestSuiteFactory } from '../../common/suites/find'; +import { findTestSuiteFactory, getTestCases } from '../../common/suites/find'; + +const createTestCases = (spaceId: string) => { + const cases = getTestCases(spaceId); + return Object.values(cases); +}; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const { - createExpectEmpty, - createExpectVisualizationResults, - expectFilterWrongTypeError, - expectNotSpaceAwareResults, - expectTypeRequired, - findTest, - } = findTestSuiteFactory(esArchiver, supertest); - - describe('find', () => { - findTest(`objects only within the current space (space_1)`, { - ...SPACES.SPACE_1, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(SPACES.SPACE_1.spaceId), - }, - notSpaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - unknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - filterWithUnknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, - }); + const { addTests, createTestDefinitions } = findTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false); + }; - findTest(`objects only within the current space (default)`, { - ...SPACES.DEFAULT, - tests: { - spaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: createExpectVisualizationResults(SPACES.DEFAULT.spaceId), - }, - notSpaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - unknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithNotSpaceAwareType: { - description: 'only the visualization', - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - filterWithHiddenType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - filterWithUnknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - filterWithNoType: { - description: 'bad request, type is required', - statusCode: 400, - response: expectTypeRequired, - }, - filterWithUnAllowedType: { - description: 'Bad Request', - statusCode: 400, - response: expectFilterWrongTypeError, - }, - }, + describe('_find', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts index 592bedd92b4d77..512ae968dd0ddd 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts @@ -5,88 +5,48 @@ */ import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { getTestSuiteFactory } from '../../common/suites/get'; +import { getTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/get'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +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() }, +]; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const { - createExpectDoesntExistNotFound, - createExpectSpaceAwareNotFound, - createExpectSpaceAwareResults, - createExpectNotSpaceAwareResults, - expectHiddenTypeNotFound: expectHiddenTypeNotFound, - getTest, - } = getTestSuiteFactory(esArchiver, supertest); - - describe('get', () => { - getTest(`can access objects belonging to the current space (default)`, { - ...SPACES.DEFAULT, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(SPACES.DEFAULT.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(SPACES.DEFAULT.spaceId), - }, - hiddenType: { - statusCode: 404, - response: expectHiddenTypeNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(SPACES.DEFAULT.spaceId), - }, - }, - }); - - getTest(`can access objects belonging to the current space (space_1)`, { - ...SPACES.SPACE_1, - tests: { - spaceAware: { - statusCode: 200, - response: createExpectSpaceAwareResults(SPACES.SPACE_1.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(SPACES.SPACE_1.spaceId), - }, - hiddenType: { - statusCode: 404, - response: expectHiddenTypeNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(SPACES.SPACE_1.spaceId), - }, - }, - }); + const { addTests, createTestDefinitions } = getTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false, { spaceId }); + }; - getTest(`can't access space aware objects belonging to another space (space_1)`, { - spaceId: SPACES.DEFAULT.spaceId, - otherSpaceId: SPACES.SPACE_1.spaceId, - tests: { - spaceAware: { - statusCode: 404, - response: createExpectSpaceAwareNotFound(SPACES.SPACE_1.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: createExpectNotSpaceAwareResults(SPACES.SPACE_1.spaceId), - }, - hiddenType: { - statusCode: 404, - response: expectHiddenTypeNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(SPACES.SPACE_1.spaceId), - }, - }, + describe('_get', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts index c78a0e1cc2ccec..5fe4b08d91b54f 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts @@ -5,56 +5,48 @@ */ import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { importTestSuiteFactory } from '../../common/suites/import'; +import { importTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/import'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail409 } = testCaseFailures; + +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, ...fail409(spaceId === DEFAULT_SPACE_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(spaceId === SPACE_2_ID) }, + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409() }, + { ...CASES.HIDDEN, ...fail400() }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, +]; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); const es = getService('legacyEs'); - const { - importTest, - createExpectResults, - expectUnknownTypeUnsupported, - expectHiddenTypeUnsupported, - } = importTestSuiteFactory(es, esArchiver, supertest); + const { addTests, createTestDefinitions } = importTestSuiteFactory(es, esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false, { spaceId, singleRequest: true }); + }; describe('_import', () => { - importTest('in the current space (space_1)', { - ...SPACES.SPACE_1, - tests: { - default: { - statusCode: 200, - response: createExpectResults(SPACES.SPACE_1.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - importTest('in the default space', { - ...SPACES.DEFAULT, - tests: { - default: { - statusCode: 200, - response: createExpectResults(SPACES.DEFAULT.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts index bb481e0c98bc1a..c2f8339d38c974 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts @@ -12,6 +12,7 @@ export default function({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./bulk_create')); loadTestFile(require.resolve('./bulk_get')); + loadTestFile(require.resolve('./bulk_update')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./export')); @@ -20,6 +21,5 @@ export default function({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./import')); loadTestFile(require.resolve('./resolve_import_errors')); loadTestFile(require.resolve('./update')); - loadTestFile(require.resolve('./bulk_update')); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts index 22a7ab81e5530f..04f9ac8414afd9 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts @@ -5,56 +5,59 @@ */ import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { resolveImportErrorsTestSuiteFactory } from '../../common/suites/resolve_import_errors'; +import { + resolveImportErrorsTestSuiteFactory, + TEST_CASES as CASES, +} from '../../common/suites/resolve_import_errors'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail400, fail409 } = testCaseFailures; + +const createTestCases = (overwrite: boolean, 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, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail400() }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail400() }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + { ...CASES.HIDDEN, ...fail400() }, + CASES.NEW_SINGLE_NAMESPACE_OBJ, + { ...CASES.NEW_MULTI_NAMESPACE_OBJ, ...fail400() }, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, +]; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); const es = getService('legacyEs'); - const { - resolveImportErrorsTest, - createExpectResults, - expectUnknownTypeUnsupported, - expectHiddenTypeUnsupported, - } = resolveImportErrorsTestSuiteFactory(es, esArchiver, supertest); + const { addTests, createTestDefinitions } = resolveImportErrorsTestSuiteFactory( + es, + esArchiver, + supertest + ); + const createTests = (overwrite: boolean, spaceId: string) => { + const testCases = createTestCases(overwrite, spaceId); + return createTestDefinitions(testCases, false, overwrite, { spaceId, singleRequest: true }); + }; describe('_resolve_import_errors', () => { - resolveImportErrorsTest('in the current space (space_1)', { - ...SPACES.SPACE_1, - tests: { - default: { - statusCode: 200, - response: createExpectResults(SPACES.SPACE_1.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, - }); - - resolveImportErrorsTest('in the default space', { - ...SPACES.DEFAULT, - tests: { - default: { - statusCode: 200, - response: createExpectResults(SPACES.DEFAULT.spaceId), - }, - hiddenType: { - statusCode: 200, - response: expectHiddenTypeUnsupported, - }, - unknownType: { - statusCode: 200, - response: expectUnknownTypeUnsupported, - }, - }, + getTestScenarios([false, true]).spaces.forEach(({ spaceId, modifier: overwrite }) => { + const suffix = overwrite ? ' with overwrite enabled' : ''; + const tests = createTests(overwrite!, spaceId); + addTests(`within the ${spaceId} space${suffix}`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts index 6ffbda511d871f..381861e33b68dd 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts @@ -5,88 +5,48 @@ */ import { SPACES } from '../../common/lib/spaces'; +import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { updateTestSuiteFactory } from '../../common/suites/update'; +import { updateTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/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 = (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() }, +]; export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - describe('update', () => { - const { - createExpectSpaceAwareNotFound, - expectSpaceAwareResults, - createExpectDoesntExistNotFound, - expectNotSpaceAwareResults, - expectSpaceNotFound, - updateTest, - } = updateTestSuiteFactory(esArchiver, supertest); - - updateTest(`in the default space`, { - spaceId: SPACES.DEFAULT.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 404, - response: expectSpaceNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(SPACES.DEFAULT.spaceId), - }, - }, - }); - - updateTest('in the current space (space_1)', { - spaceId: SPACES.SPACE_1.spaceId, - tests: { - spaceAware: { - statusCode: 200, - response: expectSpaceAwareResults, - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 404, - response: expectSpaceNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(SPACES.SPACE_1.spaceId), - }, - }, - }); + const { addTests, createTestDefinitions } = updateTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + return createTestDefinitions(testCases, false); + }; - updateTest('objects that exist in another space (space_1)', { - spaceId: SPACES.DEFAULT.spaceId, - otherSpaceId: SPACES.SPACE_1.spaceId, - tests: { - spaceAware: { - statusCode: 404, - response: createExpectSpaceAwareNotFound(SPACES.SPACE_1.spaceId), - }, - notSpaceAware: { - statusCode: 200, - response: expectNotSpaceAwareResults, - }, - hiddenType: { - statusCode: 404, - response: expectSpaceNotFound, - }, - doesntExist: { - statusCode: 404, - response: createExpectDoesntExistNotFound(), - }, - }, + describe('_update', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createTests(spaceId); + addTests(`within the ${spaceId} space`, { spaceId, tests }); }); }); } diff --git a/x-pack/test/spaces_api_integration/common/config.ts b/x-pack/test/spaces_api_integration/common/config.ts index dffc6c524cc6ec..19743e09f94209 100644 --- a/x-pack/test/spaces_api_integration/common/config.ts +++ b/x-pack/test/spaces_api_integration/common/config.ts @@ -67,6 +67,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) // disable anonymouse access so that we're testing both on and off in different suites '--status.allowAnonymous=false', '--server.xsrf.disableProtection=true', + `--plugin-path=${path.join(__dirname, 'fixtures', 'shared_type_plugin')}`, ...disabledPlugins.map(key => `--xpack.${key}.enabled=false`), ], }, diff --git a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index 64c1be0b900712..9a8a0a1fdda141 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -376,3 +376,123 @@ "type": "_doc" } } + +{ + "type": "doc", + "value": { + "id": "sharedtype:default_space_only", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in the default space" + }, + "type": "sharedtype", + "namespaces": ["default"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:space_1_only", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in the space_1 space" + }, + "type": "sharedtype", + "namespaces": ["space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:space_2_only", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in the space_2 space" + }, + "type": "sharedtype", + "namespaces": ["space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:default_and_space_1", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in the default and space_1 spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:default_and_space_2", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in the default and space_2 spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:space_1_and_space_2", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in the space_1 and space_2 spaces" + }, + "type": "sharedtype", + "namespaces": ["space_1", "space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "sharedtype:all_spaces", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in the default, space_1, and space_2 spaces" + }, + "type": "sharedtype", + "namespaces": ["default", "space_1", "space_2"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + diff --git a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index 1440585b625b9c..508de68c32f706 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -159,6 +159,9 @@ "namespace": { "type": "keyword" }, + "namespaces": { + "type": "keyword" + }, "search": { "properties": { "columns": { @@ -320,6 +323,19 @@ "type": "text" } } + }, + "sharedtype": { + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } } } }, diff --git a/x-pack/test/spaces_api_integration/common/fixtures/shared_type_plugin/index.js b/x-pack/test/spaces_api_integration/common/fixtures/shared_type_plugin/index.js new file mode 100644 index 00000000000000..91a24fb9f4f564 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/fixtures/shared_type_plugin/index.js @@ -0,0 +1,27 @@ +/* + * 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 mappings from './mappings.json'; + +export default function(kibana) { + return new kibana.Plugin({ + require: ['kibana', 'elasticsearch', 'xpack_main'], + name: 'shared_type_plugin', + uiExports: { + savedObjectsManagement: {}, + savedObjectSchemas: { + sharedtype: { + multiNamespace: true, + }, + }, + mappings, + }, + + config() {}, + + init() {}, // need empty init for plugin to load + }); +} diff --git a/x-pack/test/spaces_api_integration/common/fixtures/shared_type_plugin/mappings.json b/x-pack/test/spaces_api_integration/common/fixtures/shared_type_plugin/mappings.json new file mode 100644 index 00000000000000..918958aec0d6df --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/fixtures/shared_type_plugin/mappings.json @@ -0,0 +1,15 @@ +{ + "sharedtype": { + "properties": { + "title": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + } +} diff --git a/x-pack/test/spaces_api_integration/common/fixtures/shared_type_plugin/package.json b/x-pack/test/spaces_api_integration/common/fixtures/shared_type_plugin/package.json new file mode 100644 index 00000000000000..c52f4256c5c06b --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/fixtures/shared_type_plugin/package.json @@ -0,0 +1,7 @@ +{ + "name": "shared_type_plugin", + "version": "0.0.0", + "kibana": { + "version": "kibana" + } +} diff --git a/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts b/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts new file mode 100644 index 00000000000000..67f5d737ba010b --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts @@ -0,0 +1,40 @@ +/* + * 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. + */ + +export const MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES = Object.freeze({ + DEFAULT_SPACE_ONLY: Object.freeze({ + id: 'default_space_only', + existingNamespaces: ['default'], + }), + SPACE_1_ONLY: Object.freeze({ + id: 'space_1_only', + existingNamespaces: ['space_1'], + }), + SPACE_2_ONLY: Object.freeze({ + id: 'space_2_only', + existingNamespaces: ['space_2'], + }), + DEFAULT_AND_SPACE_1: Object.freeze({ + id: 'default_and_space_1', + existingNamespaces: ['default', 'space_1'], + }), + DEFAULT_AND_SPACE_2: Object.freeze({ + id: 'default_and_space_2', + existingNamespaces: ['default', 'space_2'], + }), + SPACE_1_AND_SPACE_2: Object.freeze({ + id: 'space_1_and_space_2', + existingNamespaces: ['space_1', 'space_2'], + }), + ALL_SPACES: Object.freeze({ + id: 'all_spaces', + existingNamespaces: ['default', 'space_1', 'space_2'], + }), + DOES_NOT_EXIST: Object.freeze({ + id: 'does_not_exist', + existingNamespaces: [] as string[], + }), +}); diff --git a/x-pack/test/spaces_api_integration/common/suites/delete.ts b/x-pack/test/spaces_api_integration/common/suites/delete.ts index 9036fcbf7a8ddc..0d8728fdf622ee 100644 --- a/x-pack/test/spaces_api_integration/common/suites/delete.ts +++ b/x-pack/test/spaces_api_integration/common/suites/delete.ts @@ -6,6 +6,7 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import { getUrlPrefix } from '../lib/space_test_utils'; +import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; interface DeleteTest { @@ -128,6 +129,25 @@ export function deleteTestSuiteFactory(es: any, esArchiver: any, supertest: Supe ]; expect(buckets).to.eql(expectedBuckets); + + // There were seven multi-namespace objects. + // Since Space 2 was deleted, any multi-namespace objects that existed in that space + // are updated to remove it, and of those, any that don't exist in any space are deleted. + const multiNamespaceResponse = await es.search({ + index: '.kibana', + body: { query: { terms: { type: ['sharedtype'] } } }, + }); + const docs: [Record] = multiNamespaceResponse.hits.hits; + expect(docs).length(6); // just six results, since spaces_2_only got deleted + Object.values(CASES).forEach(({ id, existingNamespaces }) => { + const remainingNamespaces = existingNamespaces.filter(x => x !== 'space_2'); + const doc = docs.find(x => x._id === `sharedtype:${id}`); + if (remainingNamespaces.length > 0) { + expect(doc?._source?.namespaces).to.eql(remainingNamespaces); + } else { + expect(doc).to.be(undefined); + } + }); }; const expectNotFound = (resp: { [key: string]: any }) => { diff --git a/x-pack/test/spaces_api_integration/common/suites/share_add.ts b/x-pack/test/spaces_api_integration/common/suites/share_add.ts new file mode 100644 index 00000000000000..b9a012b606da38 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/suites/share_add.ts @@ -0,0 +1,118 @@ +/* + * 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 { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; +import { SPACES } from '../lib/spaces'; +import { + expectResponses, + getUrlPrefix, +} from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { + ExpectResponseBody, + TestDefinition, + TestSuite, +} from '../../../saved_object_api_integration/common/lib/types'; + +export interface ShareAddTestDefinition extends TestDefinition { + request: { spaces: string[]; object: { type: string; id: string } }; +} +export type ShareAddTestSuite = TestSuite; +export interface ShareAddTestCase { + id: string; + namespaces: string[]; + failure?: 400 | 403 | 404; + fail400Param?: string; + fail403Param?: string; +} + +const TYPE = 'sharedtype'; +const createRequest = ({ id, namespaces }: ShareAddTestCase) => ({ + spaces: namespaces, + object: { type: TYPE, id }, +}); +const getTestTitle = ({ id, namespaces }: ShareAddTestCase) => + `{id: ${id}, namespaces: [${namespaces.join(',')}]}`; + +export function shareAddTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const expectResponseBody = (testCase: ShareAddTestCase): ExpectResponseBody => async ( + response: Record + ) => { + const { id, failure, fail400Param, fail403Param } = testCase; + const object = response.body; + if (failure === 403) { + await expectResponses.forbidden(fail403Param!)(TYPE)(response); + } else if (failure) { + let error: any; + if (failure === 400) { + error = SavedObjectsErrorHelpers.createBadRequestError( + `${id} already exists in the following namespace(s): ${fail400Param}` + ); + } else if (failure === 404) { + error = SavedObjectsErrorHelpers.createGenericNotFoundError(TYPE, id); + } + expect(object.error).to.eql(error.output.payload.error); + expect(object.statusCode).to.eql(error.output.payload.statusCode); + } else { + // success + expect(object).to.eql({}); + } + }; + const createTestDefinitions = ( + testCases: ShareAddTestCase | ShareAddTestCase[], + forbidden: boolean, + options?: { + responseBodyOverride?: ExpectResponseBody; + fail403Param?: string; + } + ): ShareAddTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + if (forbidden) { + // override the expected result in each test case + cases = cases.map(x => ({ ...x, failure: 403, fail403Param: options?.fail403Param })); + } + return cases.map(x => ({ + title: getTestTitle(x), + responseStatusCode: x.failure ?? 204, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x), + })); + }; + + const makeShareAddTest = (describeFn: Mocha.SuiteFunction) => ( + description: string, + definition: ShareAddTestSuite + ) => { + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; + + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const requestBody = test.request; + await supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_share_saved_object_add`) + .auth(user?.username, user?.password) + .send(requestBody) + .expect(test.responseStatusCode) + .then(test.responseBody); + }); + } + }); + }; + + const addTests = makeShareAddTest(describe); + // @ts-ignore + addTests.only = makeShareAddTest(describe.only); + + return { + addTests, + createTestDefinitions, + }; +} diff --git a/x-pack/test/spaces_api_integration/common/suites/share_remove.ts b/x-pack/test/spaces_api_integration/common/suites/share_remove.ts new file mode 100644 index 00000000000000..b5fcbe5a1cf2c2 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/suites/share_remove.ts @@ -0,0 +1,116 @@ +/* + * 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 { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; +import { SPACES } from '../lib/spaces'; +import { + expectResponses, + getUrlPrefix, + getTestTitle, +} from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { + ExpectResponseBody, + TestDefinition, + TestSuite, +} from '../../../saved_object_api_integration/common/lib/types'; + +export interface ShareRemoveTestDefinition extends TestDefinition { + request: { spaces: string[]; object: { type: string; id: string } }; +} +export type ShareRemoveTestSuite = TestSuite; +export interface ShareRemoveTestCase { + id: string; + namespaces: string[]; + failure?: 400 | 403 | 404; + fail400Param?: string; +} + +const TYPE = 'sharedtype'; +const createRequest = ({ id, namespaces }: ShareRemoveTestCase) => ({ + spaces: namespaces, + object: { type: TYPE, id }, +}); + +export function shareRemoveTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const expectForbidden = expectResponses.forbidden('delete'); + const expectResponseBody = (testCase: ShareRemoveTestCase): ExpectResponseBody => async ( + response: Record + ) => { + const { id, failure, fail400Param } = testCase; + const object = response.body; + if (failure === 403) { + await expectForbidden(TYPE)(response); + } else if (failure) { + let error: any; + if (failure === 400) { + error = SavedObjectsErrorHelpers.createBadRequestError( + `${id} doesn't exist in the following namespace(s): ${fail400Param}` + ); + } else if (failure === 404) { + error = SavedObjectsErrorHelpers.createGenericNotFoundError(TYPE, id); + } + expect(object.error).to.eql(error.output.payload.error); + expect(object.statusCode).to.eql(error.output.payload.statusCode); + } else { + // success + expect(object).to.eql({}); + } + }; + const createTestDefinitions = ( + testCases: ShareRemoveTestCase | ShareRemoveTestCase[], + forbidden: boolean, + options?: { + responseBodyOverride?: ExpectResponseBody; + } + ): ShareRemoveTestDefinition[] => { + let cases = Array.isArray(testCases) ? testCases : [testCases]; + if (forbidden) { + // override the expected result in each test case + cases = cases.map(x => ({ ...x, failure: 403 })); + } + return cases.map(x => ({ + title: getTestTitle({ ...x, type: TYPE }), + responseStatusCode: x.failure ?? 204, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x), + })); + }; + + const makeShareRemoveTest = (describeFn: Mocha.SuiteFunction) => ( + description: string, + definition: ShareRemoveTestSuite + ) => { + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; + + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const requestBody = test.request; + await supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_share_saved_object_remove`) + .auth(user?.username, user?.password) + .send(requestBody) + .expect(test.responseStatusCode) + .then(test.responseBody); + }); + } + }); + }; + + const addTests = makeShareRemoveTest(describe); + // @ts-ignore + addTests.only = makeShareRemoveTest(describe.only); + + return { + addTests, + createTestDefinitions, + }; +} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts index e918ab0b538418..8d85d95e6812ff 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts @@ -25,6 +25,8 @@ export default function({ loadTestFile, getService }: TestInvoker) { loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./get_all')); loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./share_add')); + loadTestFile(require.resolve('./share_remove')); loadTestFile(require.resolve('./update')); }); } diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts new file mode 100644 index 00000000000000..c7e65ac4247763 --- /dev/null +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts @@ -0,0 +1,124 @@ +/* + * 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 { + testCaseFailures, + getTestScenarios, +} from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { TestUser } from '../../../saved_object_api_integration/common/lib/types'; +import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../../common/lib/saved_object_test_cases'; +import { TestInvoker } from '../../common/lib/types'; +import { shareAddTestSuiteFactory, ShareAddTestDefinition } from '../../common/suites/share_add'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => { + const namespaces = [spaceId]; + return [ + // Test cases to check adding the target namespace to different saved objects + { ...CASES.DEFAULT_SPACE_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SPACE_1_ONLY, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SPACE_2_ONLY, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, + { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, + { ...CASES.DEFAULT_AND_SPACE_2, namespaces, ...fail404(spaceId === SPACE_1_ID) }, + { ...CASES.SPACE_1_AND_SPACE_2, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, + { ...CASES.ALL_SPACES, namespaces }, + { ...CASES.DOES_NOT_EXIST, namespaces, ...fail404() }, + // Test cases to check adding multiple namespaces to different saved objects that exist in one space + // These are non-exhaustive, they only check cases for adding two additional namespaces to a saved object + // More permutations are covered in the corresponding spaces_only test suite + { + ...CASES.DEFAULT_SPACE_ONLY, + namespaces: [SPACE_1_ID, SPACE_2_ID], + ...fail404(spaceId !== DEFAULT_SPACE_ID), + }, + { + ...CASES.SPACE_1_ONLY, + namespaces: [DEFAULT_SPACE_ID, SPACE_2_ID], + ...fail404(spaceId !== SPACE_1_ID), + }, + { + ...CASES.SPACE_2_ONLY, + namespaces: [DEFAULT_SPACE_ID, SPACE_1_ID], + ...fail404(spaceId !== SPACE_2_ID), + }, + ]; +}; +const calculateSingleSpaceAuthZ = ( + testCases: ReturnType, + spaceId: string +) => { + const targetsOtherSpace = testCases.filter( + x => !x.namespaces.includes(spaceId) || x.namespaces.length > 1 + ); + const tmp = testCases.filter(x => !targetsOtherSpace.includes(x)); // doesn't target other space + const doesntExistInThisSpace = tmp.filter(x => !x.existingNamespaces.includes(spaceId)); + const existsInThisSpace = tmp.filter(x => x.existingNamespaces.includes(spaceId)); + return { targetsOtherSpace, doesntExistInThisSpace, existsInThisSpace }; +}; +// eslint-disable-next-line import/no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = shareAddTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const testCases = createTestCases(spaceId); + const thisSpace = calculateSingleSpaceAuthZ(testCases, spaceId); + const otherSpaceId = spaceId === DEFAULT_SPACE_ID ? SPACE_1_ID : DEFAULT_SPACE_ID; + const otherSpace = calculateSingleSpaceAuthZ(testCases, otherSpaceId); + return { + unauthorized: createTestDefinitions(testCases, true, { fail403Param: 'create' }), + authorizedInSpace: [ + createTestDefinitions(thisSpace.targetsOtherSpace, true, { fail403Param: 'create' }), + createTestDefinitions(thisSpace.doesntExistInThisSpace, false), + createTestDefinitions(thisSpace.existsInThisSpace, false), + ].flat(), + authorizedInOtherSpace: [ + createTestDefinitions(otherSpace.targetsOtherSpace, true, { fail403Param: 'create' }), + // If the preflight GET request fails, it will return a 404 error; users who are authorized to create saved objects in the target + // space(s) but are not authorized to update saved objects in this space will see a 403 error instead of 404. This is a safeguard to + // prevent potential information disclosure of the spaces that a given saved object may exist in. + createTestDefinitions(otherSpace.doesntExistInThisSpace, true, { fail403Param: 'update' }), + createTestDefinitions(otherSpace.existsInThisSpace, false), + ].flat(), + authorized: createTestDefinitions(testCases, false), + }; + }; + + describe('_share_saved_object_add', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` targeting the ${spaceId} space`; + const { unauthorized, authorizedInSpace, authorizedInOtherSpace, authorized } = createTests( + spaceId + ); + const _addTests = (user: TestUser, tests: ShareAddTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; + + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + ].forEach(user => { + _addTests(user, unauthorized); + }); + _addTests(users.allAtSpace, authorizedInSpace); + _addTests(users.allAtOtherSpace, authorizedInOtherSpace); + [users.dualAll, users.allGlobally, users.superuser].forEach(user => { + _addTests(user, authorized); + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts new file mode 100644 index 00000000000000..3a8d42f620a3e0 --- /dev/null +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts @@ -0,0 +1,99 @@ +/* + * 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 { + testCaseFailures, + getTestScenarios, +} from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { TestUser } from '../../../saved_object_api_integration/common/lib/types'; +import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../../common/lib/saved_object_test_cases'; +import { TestInvoker } from '../../common/lib/types'; +import { + shareRemoveTestSuiteFactory, + ShareRemoveTestCase, + ShareRemoveTestDefinition, +} from '../../common/suites/share_remove'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +const createTestCases = (spaceId: string) => { + // Test cases to check removing the target namespace from different saved objects + let namespaces = [spaceId]; + const singleSpace = [ + { id: CASES.DEFAULT_SPACE_ONLY.id, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { id: CASES.SPACE_1_ONLY.id, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, + { id: CASES.SPACE_2_ONLY.id, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, + { id: CASES.DEFAULT_AND_SPACE_1.id, namespaces, ...fail404(spaceId === SPACE_2_ID) }, + { id: CASES.DEFAULT_AND_SPACE_2.id, namespaces, ...fail404(spaceId === SPACE_1_ID) }, + { id: CASES.SPACE_1_AND_SPACE_2.id, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, + { id: CASES.ALL_SPACES.id, namespaces }, + { id: CASES.DOES_NOT_EXIST.id, namespaces, ...fail404() }, + ] as ShareRemoveTestCase[]; + + // Test cases to check removing all three namespaces from different saved objects that exist in two spaces + // These are non-exhaustive, they only check some cases -- each object will result in a 404, either because + // it never existed in the target namespace, or it was removed in one of the test cases above + // More permutations are covered in the corresponding spaces_only test suite + namespaces = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; + const multipleSpaces = [ + { id: CASES.DEFAULT_AND_SPACE_1.id, namespaces, ...fail404() }, + { id: CASES.DEFAULT_AND_SPACE_2.id, namespaces, ...fail404() }, + { id: CASES.SPACE_1_AND_SPACE_2.id, namespaces, ...fail404() }, + ] as ShareRemoveTestCase[]; + + const allCases = singleSpace.concat(multipleSpaces); + return { singleSpace, multipleSpaces, allCases }; +}; + +// eslint-disable-next-line import/no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = shareRemoveTestSuiteFactory(esArchiver, supertest); + const createTests = (spaceId: string) => { + const { singleSpace, multipleSpaces, allCases } = createTestCases(spaceId); + return { + unauthorized: createTestDefinitions(allCases, true), + authorizedThisSpace: [ + createTestDefinitions(singleSpace, false), + createTestDefinitions(multipleSpaces, true), + ].flat(), + authorizedGlobally: createTestDefinitions(allCases, false), + }; + }; + + describe('_share_saved_object_remove', () => { + getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { + const suffix = ` targeting the ${spaceId} space`; + const { unauthorized, authorizedThisSpace, authorizedGlobally } = createTests(spaceId); + const _addTests = (user: TestUser, tests: ShareRemoveTestDefinition[]) => { + addTests(`${user.description}${suffix}`, { user, spaceId, tests }); + }; + + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.readAtSpace, + users.allAtOtherSpace, + ].forEach(user => { + _addTests(user, unauthorized); + }); + _addTests(users.allAtSpace, authorizedThisSpace); + [users.dualAll, users.allGlobally, users.superuser].forEach(user => { + _addTests(user, authorizedGlobally); + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts index 1182f6bdabcff4..b02dc35b58b56e 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts @@ -17,6 +17,8 @@ export default function spacesOnlyTestSuite({ loadTestFile }: TestInvoker) { loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./get_all')); loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./share_add')); + loadTestFile(require.resolve('./share_remove')); loadTestFile(require.resolve('./update')); }); } diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts new file mode 100644 index 00000000000000..f1e603836fa215 --- /dev/null +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts @@ -0,0 +1,84 @@ +/* + * 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 { + testCaseFailures, + getTestScenarios, +} from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { TestInvoker } from '../../common/lib/types'; +import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../../common/lib/saved_object_test_cases'; +import { shareAddTestSuiteFactory } from '../../common/suites/share_add'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +/** + * Single-namespace test cases + * @param spaceId the namespace to add to each saved object + */ +const createSingleTestCases = (spaceId: string) => { + const namespaces = ['some-space-id']; + return [ + { ...CASES.DEFAULT_SPACE_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SPACE_1_ONLY, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SPACE_2_ONLY, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, + { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, + { ...CASES.DEFAULT_AND_SPACE_2, namespaces, ...fail404(spaceId === SPACE_1_ID) }, + { ...CASES.SPACE_1_AND_SPACE_2, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, + { ...CASES.ALL_SPACES, namespaces }, + { ...CASES.DOES_NOT_EXIST, namespaces, ...fail404() }, + ]; +}; +/** + * Multi-namespace test cases + * These are non-exhaustive, but they check different permutations of saved objects and spaces to add + */ +const createMultiTestCases = () => { + const allSpaces = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; + let id = CASES.DEFAULT_SPACE_ONLY.id; + const one = [{ id, namespaces: allSpaces }]; + id = CASES.DEFAULT_AND_SPACE_1.id; + const two = [{ id, namespaces: allSpaces }]; + id = CASES.ALL_SPACES.id; + const three = [{ id, namespaces: allSpaces }]; + return { one, two, three }; +}; + +// eslint-disable-next-line import/no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = shareAddTestSuiteFactory(esArchiver, supertest); + const createSingleTests = (spaceId: string) => { + const testCases = createSingleTestCases(spaceId); + return createTestDefinitions(testCases, false); + }; + const createMultiTests = () => { + const testCases = createMultiTestCases(); + return { + one: createTestDefinitions(testCases.one, false), + two: createTestDefinitions(testCases.two, false), + three: createTestDefinitions(testCases.three, false), + }; + }; + + describe('_share_saved_object_add', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createSingleTests(spaceId); + addTests(`targeting the ${spaceId} space`, { spaceId, tests }); + }); + const { one, two, three } = createMultiTests(); + addTests('for a saved object in the default space', { tests: one }); + addTests('for a saved object in the default and space_1 spaces', { tests: two }); + addTests('for a saved object in the default, space_1, and space_2 spaces', { tests: three }); + }); +} diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts new file mode 100644 index 00000000000000..15be72c9f09ac4 --- /dev/null +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts @@ -0,0 +1,99 @@ +/* + * 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 { + testCaseFailures, + getTestScenarios, +} from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { TestInvoker } from '../../common/lib/types'; +import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../../common/lib/saved_object_test_cases'; +import { shareRemoveTestSuiteFactory } from '../../common/suites/share_remove'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const { fail404 } = testCaseFailures; + +/** + * Single-namespace test cases + * @param spaceId the namespace to remove from each saved object + */ +const createSingleTestCases = (spaceId: string) => { + const namespaces = [spaceId]; + return [ + { ...CASES.DEFAULT_SPACE_ONLY, namespaces, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, + { ...CASES.SPACE_1_ONLY, namespaces, ...fail404(spaceId !== SPACE_1_ID) }, + { ...CASES.SPACE_2_ONLY, namespaces, ...fail404(spaceId !== SPACE_2_ID) }, + { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, + { ...CASES.DEFAULT_AND_SPACE_2, namespaces, ...fail404(spaceId === SPACE_1_ID) }, + { ...CASES.SPACE_1_AND_SPACE_2, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, + { ...CASES.ALL_SPACES, namespaces }, + { ...CASES.DOES_NOT_EXIST, namespaces, ...fail404() }, + ]; +}; +/** + * Multi-namespace test cases + * These are non-exhaustive, but they check different permutations of saved objects and spaces to remove + */ +const createMultiTestCases = () => { + const nonExistentSpaceId = 'does_not_exist'; // space that doesn't exist + let id = CASES.DEFAULT_SPACE_ONLY.id; + const one = [ + { id, namespaces: [nonExistentSpaceId] }, + { id, namespaces: [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID] }, + { id, namespaces: [DEFAULT_SPACE_ID], ...fail404() }, // this saved object no longer exists + ]; + id = CASES.DEFAULT_AND_SPACE_1.id; + const two = [ + { id, namespaces: [DEFAULT_SPACE_ID, nonExistentSpaceId] }, + // this saved object will not be found in the context of the current namespace ('default') + { id, namespaces: [DEFAULT_SPACE_ID], ...fail404() }, // this object's namespaces no longer contains DEFAULT_SPACE_ID + { id, namespaces: [SPACE_1_ID], ...fail404() }, // this object's namespaces does contain SPACE_1_ID + ]; + id = CASES.ALL_SPACES.id; + const three = [ + { id, namespaces: [DEFAULT_SPACE_ID, SPACE_1_ID, nonExistentSpaceId] }, + // this saved object will not be found in the context of the current namespace ('default') + { id, namespaces: [DEFAULT_SPACE_ID], ...fail404() }, // this object's namespaces no longer contains DEFAULT_SPACE_ID + { id, namespaces: [SPACE_1_ID], ...fail404() }, // this object's namespaces no longer contains SPACE_1_ID + { id, namespaces: [SPACE_2_ID], ...fail404() }, // this object's namespaces does contain SPACE_2_ID + ]; + return { one, two, three }; +}; + +// eslint-disable-next-line import/no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const { addTests, createTestDefinitions } = shareRemoveTestSuiteFactory(esArchiver, supertest); + const createSingleTests = (spaceId: string) => { + const testCases = createSingleTestCases(spaceId); + return createTestDefinitions(testCases, false); + }; + const createMultiTests = () => { + const testCases = createMultiTestCases(); + return { + one: createTestDefinitions(testCases.one, false), + two: createTestDefinitions(testCases.two, false), + three: createTestDefinitions(testCases.three, false), + }; + }; + + describe('_share_saved_object_remove', () => { + getTestScenarios().spaces.forEach(({ spaceId }) => { + const tests = createSingleTests(spaceId); + addTests(`targeting the ${spaceId} space`, { spaceId, tests }); + }); + const { one, two, three } = createMultiTests(); + addTests('for a saved object in the default space', { tests: one }); + addTests('for a saved object in the default and space_1 spaces', { tests: two }); + addTests('for a saved object in the default, space_1, and space_2 spaces', { tests: three }); + }); +}