mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
Return import errors for saved objects referencing a missing index pattern or search (#33005) (#33277)
* cherry-pick fd2bc9b * Return errors when objects are missing references * Fix import tslint * Fix failing jest tests * Fix x-pack integration tests * Rename ensureReferencesExist to validateReferences * Fix test naming to use validateReferences * Update resolve_import_errors API to reflect new type attribute * Validate references for search type as well * Clarify comment * Apply PR feedback * Modify saved object bulkGet to be able to filter fields * Apply PR feedback
This commit is contained in:
parent
c1e28581d4
commit
17f2f23f11
16 changed files with 854 additions and 43 deletions
|
@ -23,6 +23,9 @@ contains the following properties:
|
|||
`id` (required)::
|
||||
(string) ID of object to retrieve
|
||||
|
||||
`fields` (optional)::
|
||||
(array) The fields to return in the object's response
|
||||
|
||||
==== Response body
|
||||
|
||||
The response body will have a top level `saved_objects` property that contains
|
||||
|
|
|
@ -45,18 +45,36 @@ describe('extractErrors()', () => {
|
|||
message: 'Conflict',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'dashboard',
|
||||
attributes: {},
|
||||
references: [],
|
||||
error: {
|
||||
statusCode: 400,
|
||||
message: 'Bad Request',
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = extractErrors(savedObjects);
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"error": Object {
|
||||
"message": "Conflict",
|
||||
"statusCode": 409,
|
||||
"type": "conflict",
|
||||
},
|
||||
"id": "2",
|
||||
"type": "dashboard",
|
||||
},
|
||||
Object {
|
||||
"error": Object {
|
||||
"message": "Bad Request",
|
||||
"statusCode": 400,
|
||||
"type": "unknown",
|
||||
},
|
||||
"id": "3",
|
||||
"type": "dashboard",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
|
|
@ -18,24 +18,29 @@
|
|||
*/
|
||||
|
||||
import { SavedObject } from '../service';
|
||||
|
||||
export interface CustomError {
|
||||
id: string;
|
||||
type: string;
|
||||
error: {
|
||||
message: string;
|
||||
statusCode: number;
|
||||
};
|
||||
}
|
||||
import { ImportError } from './types';
|
||||
|
||||
export function extractErrors(savedObjects: SavedObject[]) {
|
||||
const errors: CustomError[] = [];
|
||||
const errors: ImportError[] = [];
|
||||
for (const savedObject of savedObjects) {
|
||||
if (savedObject.error) {
|
||||
if (savedObject.error.statusCode === 409) {
|
||||
errors.push({
|
||||
id: savedObject.id,
|
||||
type: savedObject.type,
|
||||
error: {
|
||||
type: 'conflict',
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
errors.push({
|
||||
id: savedObject.id,
|
||||
type: savedObject.type,
|
||||
error: savedObject.error,
|
||||
error: {
|
||||
...savedObject.error,
|
||||
type: 'unknown',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -76,6 +76,7 @@ describe('importSavedObjects()', () => {
|
|||
this.push(null);
|
||||
},
|
||||
});
|
||||
savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] });
|
||||
savedObjectsClient.bulkCreate.mockResolvedValue({
|
||||
saved_objects: savedObjects,
|
||||
});
|
||||
|
@ -143,6 +144,7 @@ Object {
|
|||
this.push(null);
|
||||
},
|
||||
});
|
||||
savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] });
|
||||
savedObjectsClient.bulkCreate.mockResolvedValue({
|
||||
saved_objects: savedObjects,
|
||||
});
|
||||
|
@ -210,6 +212,7 @@ Object {
|
|||
this.push(null);
|
||||
},
|
||||
});
|
||||
savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] });
|
||||
savedObjectsClient.bulkCreate.mockResolvedValue({
|
||||
saved_objects: savedObjects.map(savedObject => ({
|
||||
type: savedObject.type,
|
||||
|
@ -231,32 +234,28 @@ Object {
|
|||
"errors": Array [
|
||||
Object {
|
||||
"error": Object {
|
||||
"message": "conflict",
|
||||
"statusCode": 409,
|
||||
"type": "conflict",
|
||||
},
|
||||
"id": "1",
|
||||
"type": "index-pattern",
|
||||
},
|
||||
Object {
|
||||
"error": Object {
|
||||
"message": "conflict",
|
||||
"statusCode": 409,
|
||||
"type": "conflict",
|
||||
},
|
||||
"id": "2",
|
||||
"type": "search",
|
||||
},
|
||||
Object {
|
||||
"error": Object {
|
||||
"message": "conflict",
|
||||
"statusCode": 409,
|
||||
"type": "conflict",
|
||||
},
|
||||
"id": "3",
|
||||
"type": "visualization",
|
||||
},
|
||||
Object {
|
||||
"error": Object {
|
||||
"message": "conflict",
|
||||
"statusCode": 409,
|
||||
"type": "conflict",
|
||||
},
|
||||
"id": "4",
|
||||
"type": "dashboard",
|
||||
|
|
|
@ -20,7 +20,9 @@
|
|||
import { Readable } from 'stream';
|
||||
import { SavedObjectsClient } from '../service';
|
||||
import { collectSavedObjects } from './collect_saved_objects';
|
||||
import { CustomError, extractErrors } from './extract_errors';
|
||||
import { extractErrors } from './extract_errors';
|
||||
import { ImportError } from './types';
|
||||
import { validateReferences } from './validate_references';
|
||||
|
||||
interface ImportSavedObjectsOptions {
|
||||
readStream: Readable;
|
||||
|
@ -32,7 +34,7 @@ interface ImportSavedObjectsOptions {
|
|||
interface ImportResponse {
|
||||
success: boolean;
|
||||
successCount: number;
|
||||
errors?: CustomError[];
|
||||
errors?: ImportError[];
|
||||
}
|
||||
|
||||
export async function importSavedObjects({
|
||||
|
@ -41,23 +43,29 @@ export async function importSavedObjects({
|
|||
overwrite,
|
||||
savedObjectsClient,
|
||||
}: ImportSavedObjectsOptions): Promise<ImportResponse> {
|
||||
const objectsToImport = await collectSavedObjects(readStream, objectLimit);
|
||||
const objectsFromStream = await collectSavedObjects(readStream, objectLimit);
|
||||
|
||||
if (objectsToImport.length === 0) {
|
||||
const { filteredObjects, errors: validationErrors } = await validateReferences(
|
||||
objectsFromStream,
|
||||
savedObjectsClient
|
||||
);
|
||||
|
||||
if (filteredObjects.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
success: validationErrors.length === 0,
|
||||
successCount: 0,
|
||||
...(validationErrors.length ? { errors: validationErrors } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const bulkCreateResult = await savedObjectsClient.bulkCreate(objectsToImport, {
|
||||
const bulkCreateResult = await savedObjectsClient.bulkCreate(filteredObjects, {
|
||||
overwrite,
|
||||
});
|
||||
const errors = extractErrors(bulkCreateResult.saved_objects);
|
||||
const errors = [...validationErrors, ...extractErrors(bulkCreateResult.saved_objects)];
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
successCount: objectsToImport.length - errors.length,
|
||||
successCount: objectsFromStream.length - errors.length,
|
||||
...(errors.length ? { errors } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -21,7 +21,8 @@ import { Readable } from 'stream';
|
|||
import { SavedObjectsClient } from '../service';
|
||||
import { collectSavedObjects } from './collect_saved_objects';
|
||||
import { createObjectsFilter } from './create_objects_filter';
|
||||
import { CustomError, extractErrors } from './extract_errors';
|
||||
import { extractErrors } from './extract_errors';
|
||||
import { ImportError } from './types';
|
||||
|
||||
interface ResolveImportErrorsOptions {
|
||||
readStream: Readable;
|
||||
|
@ -45,7 +46,7 @@ interface ResolveImportErrorsOptions {
|
|||
interface ImportResponse {
|
||||
success: boolean;
|
||||
successCount: number;
|
||||
errors?: CustomError[];
|
||||
errors?: ImportError[];
|
||||
}
|
||||
|
||||
export async function resolveImportErrors({
|
||||
|
@ -56,7 +57,7 @@ export async function resolveImportErrors({
|
|||
savedObjectsClient,
|
||||
replaceReferences,
|
||||
}: ResolveImportErrorsOptions): Promise<ImportResponse> {
|
||||
let errors: CustomError[] = [];
|
||||
let errors: ImportError[] = [];
|
||||
const filter = createObjectsFilter(skips, overwrites, replaceReferences);
|
||||
const objectsToResolve = await collectSavedObjects(readStream, objectLimit, filter);
|
||||
|
||||
|
|
42
src/legacy/server/saved_objects/import/types.ts
Normal file
42
src/legacy/server/saved_objects/import/types.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
interface ConflictError {
|
||||
type: 'conflict';
|
||||
}
|
||||
|
||||
interface UnknownError {
|
||||
type: 'unknown';
|
||||
message: string;
|
||||
statusCode: number;
|
||||
}
|
||||
|
||||
interface MissingReferencesError {
|
||||
type: 'missing_references';
|
||||
references: Array<{
|
||||
type: string;
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ImportError {
|
||||
id: string;
|
||||
type: string;
|
||||
error: ConflictError | MissingReferencesError | UnknownError;
|
||||
}
|
|
@ -0,0 +1,586 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { getNonExistingReferenceAsKeys, validateReferences } from './validate_references';
|
||||
|
||||
describe('getNonExistingReferenceAsKeys()', () => {
|
||||
const savedObjectsClient = {
|
||||
errors: {} as any,
|
||||
bulkCreate: jest.fn(),
|
||||
bulkGet: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
find: jest.fn(),
|
||||
get: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
test('returns empty response when no objects exist', async () => {
|
||||
const result = await getNonExistingReferenceAsKeys([], savedObjectsClient);
|
||||
expect(result).toEqual([]);
|
||||
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('removes references that exist within savedObjects', async () => {
|
||||
const savedObjects = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'index-pattern',
|
||||
attributes: {},
|
||||
references: [],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'visualization',
|
||||
attributes: {},
|
||||
references: [
|
||||
{
|
||||
name: 'ref_0',
|
||||
type: 'index-pattern',
|
||||
id: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = await getNonExistingReferenceAsKeys(savedObjects, savedObjectsClient);
|
||||
expect(result).toEqual([]);
|
||||
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('removes references that exist within es', async () => {
|
||||
const savedObjects = [
|
||||
{
|
||||
id: '2',
|
||||
type: 'visualization',
|
||||
attributes: {},
|
||||
references: [
|
||||
{
|
||||
name: 'ref_0',
|
||||
type: 'index-pattern',
|
||||
id: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
savedObjectsClient.bulkGet.mockResolvedValueOnce({
|
||||
saved_objects: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'index-pattern',
|
||||
attributes: {},
|
||||
references: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await getNonExistingReferenceAsKeys(savedObjects, savedObjectsClient);
|
||||
expect(result).toEqual([]);
|
||||
expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(`
|
||||
[MockFunction] {
|
||||
"calls": Array [
|
||||
Array [
|
||||
Array [
|
||||
Object {
|
||||
"fields": Array [
|
||||
"id",
|
||||
],
|
||||
"id": "1",
|
||||
"type": "index-pattern",
|
||||
},
|
||||
],
|
||||
],
|
||||
],
|
||||
"results": Array [
|
||||
Object {
|
||||
"type": "return",
|
||||
"value": Promise {},
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test(`doesn't handle saved object types outside of ENFORCED_TYPES`, async () => {
|
||||
const savedObjects = [
|
||||
{
|
||||
id: '2',
|
||||
type: 'visualization',
|
||||
attributes: {},
|
||||
references: [
|
||||
{
|
||||
name: 'ref_0',
|
||||
type: 'foo',
|
||||
id: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = await getNonExistingReferenceAsKeys(savedObjects, savedObjectsClient);
|
||||
expect(result).toEqual([]);
|
||||
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('returns references within ENFORCED_TYPES when they are missing', async () => {
|
||||
const savedObjects = [
|
||||
{
|
||||
id: '2',
|
||||
type: 'visualization',
|
||||
attributes: {},
|
||||
references: [
|
||||
{
|
||||
name: 'ref_0',
|
||||
type: 'index-pattern',
|
||||
id: '1',
|
||||
},
|
||||
{
|
||||
name: 'ref_1',
|
||||
type: 'search',
|
||||
id: '3',
|
||||
},
|
||||
{
|
||||
name: 'ref_2',
|
||||
type: 'foo',
|
||||
id: '4',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
savedObjectsClient.bulkGet.mockResolvedValueOnce({
|
||||
saved_objects: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'index-pattern',
|
||||
error: {
|
||||
statusCode: 404,
|
||||
message: 'Not found',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'search',
|
||||
error: {
|
||||
statusCode: 404,
|
||||
message: 'Not found',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await getNonExistingReferenceAsKeys(savedObjects, savedObjectsClient);
|
||||
expect(result).toEqual(['index-pattern:1', 'search:3']);
|
||||
expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(`
|
||||
[MockFunction] {
|
||||
"calls": Array [
|
||||
Array [
|
||||
Array [
|
||||
Object {
|
||||
"fields": Array [
|
||||
"id",
|
||||
],
|
||||
"id": "1",
|
||||
"type": "index-pattern",
|
||||
},
|
||||
Object {
|
||||
"fields": Array [
|
||||
"id",
|
||||
],
|
||||
"id": "3",
|
||||
"type": "search",
|
||||
},
|
||||
],
|
||||
],
|
||||
],
|
||||
"results": Array [
|
||||
Object {
|
||||
"type": "return",
|
||||
"value": Promise {},
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateReferences()', () => {
|
||||
const savedObjectsClient = {
|
||||
errors: {} as any,
|
||||
bulkCreate: jest.fn(),
|
||||
bulkGet: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
find: jest.fn(),
|
||||
get: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
test('returns empty when no objects are passed in', async () => {
|
||||
const result = await validateReferences([], savedObjectsClient);
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"errors": Array [],
|
||||
"filteredObjects": Array [],
|
||||
}
|
||||
`);
|
||||
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('returns errors when references are missing', async () => {
|
||||
savedObjectsClient.bulkGet.mockResolvedValue({
|
||||
saved_objects: [
|
||||
{
|
||||
type: 'index-pattern',
|
||||
id: '3',
|
||||
error: {
|
||||
statusCode: 404,
|
||||
message: 'Not found',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'index-pattern',
|
||||
id: '5',
|
||||
error: {
|
||||
statusCode: 404,
|
||||
message: 'Not found',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'index-pattern',
|
||||
id: '6',
|
||||
error: {
|
||||
statusCode: 404,
|
||||
message: 'Not found',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'search',
|
||||
id: '7',
|
||||
error: {
|
||||
statusCode: 404,
|
||||
message: 'Not found',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
type: 'search',
|
||||
attributes: {},
|
||||
references: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const savedObjects = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'visualization',
|
||||
attributes: {},
|
||||
references: [],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'visualization',
|
||||
attributes: {},
|
||||
references: [
|
||||
{
|
||||
name: 'ref_0',
|
||||
type: 'index-pattern',
|
||||
id: '3',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'visualization',
|
||||
attributes: {},
|
||||
references: [
|
||||
{
|
||||
name: 'ref_0',
|
||||
type: 'index-pattern',
|
||||
id: '5',
|
||||
},
|
||||
{
|
||||
name: 'ref_1',
|
||||
type: 'index-pattern',
|
||||
id: '6',
|
||||
},
|
||||
{
|
||||
name: 'ref_2',
|
||||
type: 'search',
|
||||
id: '7',
|
||||
},
|
||||
{
|
||||
name: 'ref_3',
|
||||
type: 'search',
|
||||
id: '8',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = await validateReferences(savedObjects, savedObjectsClient);
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"errors": Array [
|
||||
Object {
|
||||
"error": Object {
|
||||
"references": Array [
|
||||
Object {
|
||||
"id": "3",
|
||||
"type": "index-pattern",
|
||||
},
|
||||
],
|
||||
"type": "missing_references",
|
||||
},
|
||||
"id": "2",
|
||||
"type": "visualization",
|
||||
},
|
||||
Object {
|
||||
"error": Object {
|
||||
"references": Array [
|
||||
Object {
|
||||
"id": "5",
|
||||
"type": "index-pattern",
|
||||
},
|
||||
Object {
|
||||
"id": "6",
|
||||
"type": "index-pattern",
|
||||
},
|
||||
Object {
|
||||
"id": "7",
|
||||
"type": "search",
|
||||
},
|
||||
],
|
||||
"type": "missing_references",
|
||||
},
|
||||
"id": "4",
|
||||
"type": "visualization",
|
||||
},
|
||||
],
|
||||
"filteredObjects": Array [
|
||||
Object {
|
||||
"attributes": Object {},
|
||||
"id": "1",
|
||||
"references": Array [],
|
||||
"type": "visualization",
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
expect(savedObjectsClient.bulkGet).toMatchInlineSnapshot(`
|
||||
[MockFunction] {
|
||||
"calls": Array [
|
||||
Array [
|
||||
Array [
|
||||
Object {
|
||||
"fields": Array [
|
||||
"id",
|
||||
],
|
||||
"id": "3",
|
||||
"type": "index-pattern",
|
||||
},
|
||||
Object {
|
||||
"fields": Array [
|
||||
"id",
|
||||
],
|
||||
"id": "5",
|
||||
"type": "index-pattern",
|
||||
},
|
||||
Object {
|
||||
"fields": Array [
|
||||
"id",
|
||||
],
|
||||
"id": "6",
|
||||
"type": "index-pattern",
|
||||
},
|
||||
Object {
|
||||
"fields": Array [
|
||||
"id",
|
||||
],
|
||||
"id": "7",
|
||||
"type": "search",
|
||||
},
|
||||
Object {
|
||||
"fields": Array [
|
||||
"id",
|
||||
],
|
||||
"id": "8",
|
||||
"type": "search",
|
||||
},
|
||||
],
|
||||
],
|
||||
],
|
||||
"results": Array [
|
||||
Object {
|
||||
"type": "return",
|
||||
"value": Promise {},
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test(`doesn't return errors when references exist in Elasticsearch`, async () => {
|
||||
savedObjectsClient.bulkGet.mockResolvedValue({
|
||||
saved_objects: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'index-pattern',
|
||||
attributes: {},
|
||||
references: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const savedObjects = [
|
||||
{
|
||||
id: '2',
|
||||
type: 'visualization',
|
||||
attributes: {},
|
||||
references: [
|
||||
{
|
||||
name: 'ref_0',
|
||||
type: 'index-pattern',
|
||||
id: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = await validateReferences(savedObjects, savedObjectsClient);
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"errors": Array [],
|
||||
"filteredObjects": Array [
|
||||
Object {
|
||||
"attributes": Object {},
|
||||
"id": "2",
|
||||
"references": Array [
|
||||
Object {
|
||||
"id": "1",
|
||||
"name": "ref_0",
|
||||
"type": "index-pattern",
|
||||
},
|
||||
],
|
||||
"type": "visualization",
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test(`doesn't return errors when references exist within the saved objects`, async () => {
|
||||
const savedObjects = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'index-pattern',
|
||||
attributes: {},
|
||||
references: [],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'visualization',
|
||||
attributes: {},
|
||||
references: [
|
||||
{
|
||||
name: 'ref_0',
|
||||
type: 'index-pattern',
|
||||
id: '1',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = await validateReferences(savedObjects, savedObjectsClient);
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"errors": Array [],
|
||||
"filteredObjects": Array [
|
||||
Object {
|
||||
"attributes": Object {},
|
||||
"id": "1",
|
||||
"references": Array [],
|
||||
"type": "index-pattern",
|
||||
},
|
||||
Object {
|
||||
"attributes": Object {},
|
||||
"id": "2",
|
||||
"references": Array [
|
||||
Object {
|
||||
"id": "1",
|
||||
"name": "ref_0",
|
||||
"type": "index-pattern",
|
||||
},
|
||||
],
|
||||
"type": "visualization",
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test(`doesn't validate references on types not part of ENFORCED_TYPES`, async () => {
|
||||
const savedObjects = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'dashboard',
|
||||
attributes: {},
|
||||
references: [
|
||||
{
|
||||
name: 'ref_0',
|
||||
type: 'visualization',
|
||||
id: '2',
|
||||
},
|
||||
{
|
||||
name: 'ref_1',
|
||||
type: 'other-type',
|
||||
id: '3',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = await validateReferences(savedObjects, savedObjectsClient);
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"errors": Array [],
|
||||
"filteredObjects": Array [
|
||||
Object {
|
||||
"attributes": Object {},
|
||||
"id": "1",
|
||||
"references": Array [
|
||||
Object {
|
||||
"id": "2",
|
||||
"name": "ref_0",
|
||||
"type": "visualization",
|
||||
},
|
||||
Object {
|
||||
"id": "3",
|
||||
"name": "ref_1",
|
||||
"type": "other-type",
|
||||
},
|
||||
],
|
||||
"type": "dashboard",
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { SavedObject, SavedObjectsClient } from '../service';
|
||||
import { ImportError } from './types';
|
||||
|
||||
const ENFORCED_TYPES = ['index-pattern', 'search'];
|
||||
|
||||
export async function getNonExistingReferenceAsKeys(
|
||||
savedObjects: SavedObject[],
|
||||
savedObjectsClient: SavedObjectsClient
|
||||
) {
|
||||
const collector = new Map();
|
||||
for (const savedObject of savedObjects) {
|
||||
for (const { type, id } of savedObject.references || []) {
|
||||
if (!ENFORCED_TYPES.includes(type)) {
|
||||
continue;
|
||||
}
|
||||
collector.set(`${type}:${id}`, { type, id });
|
||||
}
|
||||
}
|
||||
for (const savedObject of savedObjects) {
|
||||
collector.delete(`${savedObject.type}:${savedObject.id}`);
|
||||
}
|
||||
if (collector.size) {
|
||||
const bulkGetOpts = Array.from(collector.values()).map(obj => ({ ...obj, fields: ['id'] }));
|
||||
const bulkGetResponse = await savedObjectsClient.bulkGet(bulkGetOpts);
|
||||
for (const savedObject of bulkGetResponse.saved_objects) {
|
||||
if (savedObject.error) {
|
||||
continue;
|
||||
}
|
||||
collector.delete(`${savedObject.type}:${savedObject.id}`);
|
||||
}
|
||||
}
|
||||
return [...collector.keys()];
|
||||
}
|
||||
|
||||
export async function validateReferences(
|
||||
savedObjects: SavedObject[],
|
||||
savedObjectsClient: SavedObjectsClient
|
||||
) {
|
||||
const errors: ImportError[] = [];
|
||||
|
||||
const nonExistingReferenceKeys = await getNonExistingReferenceAsKeys(
|
||||
savedObjects,
|
||||
savedObjectsClient
|
||||
);
|
||||
|
||||
// Filter out objects with missing references, add to error object
|
||||
const filteredObjects = savedObjects.filter(savedObject => {
|
||||
const missingReferences = [];
|
||||
for (const { type: refType, id: refId } of savedObject.references || []) {
|
||||
if (!ENFORCED_TYPES.includes(refType)) {
|
||||
continue;
|
||||
}
|
||||
if (nonExistingReferenceKeys.includes(`${refType}:${refId}`)) {
|
||||
missingReferences.push({ type: refType, id: refId });
|
||||
}
|
||||
}
|
||||
if (missingReferences.length) {
|
||||
errors.push({
|
||||
id: savedObject.id,
|
||||
type: savedObject.type,
|
||||
error: {
|
||||
type: 'missing_references',
|
||||
references: missingReferences,
|
||||
},
|
||||
});
|
||||
}
|
||||
return missingReferences.length === 0;
|
||||
});
|
||||
|
||||
return {
|
||||
errors,
|
||||
filteredObjects,
|
||||
};
|
||||
}
|
|
@ -29,6 +29,7 @@ interface BulkGetRequest extends Hapi.Request {
|
|||
payload: Array<{
|
||||
type: string;
|
||||
id: string;
|
||||
fields?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
|
@ -42,6 +43,7 @@ export const createBulkGetRoute = (prereqs: Prerequisites) => ({
|
|||
Joi.object({
|
||||
type: Joi.string().required(),
|
||||
id: Joi.string().required(),
|
||||
fields: Joi.array().items(Joi.string()),
|
||||
}).required()
|
||||
),
|
||||
},
|
||||
|
|
|
@ -72,6 +72,7 @@ describe('POST /api/saved_objects/_import', () => {
|
|||
'content-Type': 'multipart/form-data; boundary=BOUNDARY',
|
||||
},
|
||||
};
|
||||
savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] });
|
||||
const { payload, statusCode } = await server.inject(request);
|
||||
const response = JSON.parse(payload);
|
||||
expect(statusCode).toBe(200);
|
||||
|
@ -100,6 +101,7 @@ describe('POST /api/saved_objects/_import', () => {
|
|||
'content-Type': 'multipart/form-data; boundary=EXAMPLE',
|
||||
},
|
||||
};
|
||||
savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] });
|
||||
savedObjectsClient.bulkCreate.mockResolvedValueOnce({
|
||||
saved_objects: [
|
||||
{
|
||||
|
@ -145,6 +147,7 @@ describe('POST /api/saved_objects/_import', () => {
|
|||
'content-Type': 'multipart/form-data; boundary=EXAMPLE',
|
||||
},
|
||||
};
|
||||
savedObjectsClient.find.mockResolvedValueOnce({ saved_objects: [] });
|
||||
savedObjectsClient.bulkCreate.mockResolvedValueOnce({
|
||||
saved_objects: [
|
||||
{
|
||||
|
@ -178,8 +181,7 @@ describe('POST /api/saved_objects/_import', () => {
|
|||
id: 'my-pattern',
|
||||
type: 'index-pattern',
|
||||
error: {
|
||||
statusCode: 409,
|
||||
message: 'version conflict, document already exists',
|
||||
type: 'conflict',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -414,7 +414,7 @@ export class SavedObjectsRepository {
|
|||
/**
|
||||
* Returns an array of objects by id
|
||||
*
|
||||
* @param {array} objects - an array ids, or an array of objects containing id and optionally type
|
||||
* @param {array} objects - an array of objects containing id, type and optionally fields
|
||||
* @param {object} [options={}]
|
||||
* @property {string} [options.namespace]
|
||||
* @returns {promise} - { saved_objects: [{ id, type, version, attributes }] }
|
||||
|
@ -436,10 +436,11 @@ export class SavedObjectsRepository {
|
|||
const response = await this._callCluster('mget', {
|
||||
index: this._index,
|
||||
body: {
|
||||
docs: objects.reduce((acc, { type, id }) => {
|
||||
docs: objects.reduce((acc, { type, id, fields }) => {
|
||||
if (this._isTypeAllowed(type)) {
|
||||
acc.push({
|
||||
_id: this._serializer.generateRawId(namespace, type, id),
|
||||
_source: includedFields(type, fields),
|
||||
});
|
||||
} else {
|
||||
unsupportedTypes.push({
|
||||
|
|
|
@ -68,6 +68,7 @@ export interface UpdateOptions extends BaseOptions {
|
|||
export interface BulkGetObject {
|
||||
id: string;
|
||||
type: string;
|
||||
fields?: string[];
|
||||
}
|
||||
export type BulkGetObjects = BulkGetObject[];
|
||||
|
||||
|
|
|
@ -57,7 +57,7 @@ export default function ({ getService }) {
|
|||
});
|
||||
});
|
||||
|
||||
it('should return 409 when conflicts exist', async () => {
|
||||
it('should return errors when conflicts exist', async () => {
|
||||
await supertest
|
||||
.post('/api/saved_objects/_import')
|
||||
.attach('file', join(__dirname, '../../fixtures/import.ndjson'))
|
||||
|
@ -71,24 +71,21 @@ export default function ({ getService }) {
|
|||
id: '91200a00-9efd-11e7-acb3-3dab96693fab',
|
||||
type: 'index-pattern',
|
||||
error: {
|
||||
statusCode: 409,
|
||||
message: 'version conflict, document already exists',
|
||||
type: 'conflict',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
|
||||
type: 'visualization',
|
||||
error: {
|
||||
statusCode: 409,
|
||||
message: 'version conflict, document already exists',
|
||||
type: 'conflict',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
|
||||
type: 'dashboard',
|
||||
error: {
|
||||
statusCode: 409,
|
||||
message: 'version conflict, document already exists',
|
||||
type: 'conflict',
|
||||
}
|
||||
},
|
||||
],
|
||||
|
@ -129,6 +126,57 @@ export default function ({ getService }) {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should return errors when index patterns or search are missing', async () => {
|
||||
const objectsToImport = [
|
||||
JSON.stringify({
|
||||
type: 'visualization',
|
||||
id: '1',
|
||||
attributes: {},
|
||||
references: [
|
||||
{
|
||||
name: 'ref_0',
|
||||
type: 'index-pattern',
|
||||
id: 'non-existing',
|
||||
},
|
||||
{
|
||||
name: 'ref_1',
|
||||
type: 'search',
|
||||
id: 'non-existing-search',
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
await supertest
|
||||
.post('/api/saved_objects/_import')
|
||||
.attach('file', Buffer.from(objectsToImport.join('\n'), 'utf8'), 'export.ndjson')
|
||||
.expect(200)
|
||||
.then((resp) => {
|
||||
expect(resp.body).to.eql({
|
||||
success: false,
|
||||
successCount: 0,
|
||||
errors: [
|
||||
{
|
||||
type: 'visualization',
|
||||
id: '1',
|
||||
error: {
|
||||
type: 'missing_references',
|
||||
references: [
|
||||
{
|
||||
type: 'index-pattern',
|
||||
id: 'non-existing',
|
||||
},
|
||||
{
|
||||
type: 'search',
|
||||
id: 'non-existing-search',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -65,6 +65,7 @@ export function importTestSuiteFactory(es: any, esArchiver: any, supertest: Supe
|
|||
message: `Unsupported saved object type: 'wigwags': Bad Request`,
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
type: 'unknown',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -69,6 +69,7 @@ export function resolveImportErrorsTestSuiteFactory(
|
|||
message: `Unsupported saved object type: 'wigwags': Bad Request`,
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
type: 'unknown',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue