Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SOR] validation schema: use previous version #156665

Merged
merged 4 commits into from
May 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
private _mappings: IndexMapping;
private _registry: ISavedObjectTypeRegistry;
private _allowedTypes: string[];
private typeValidatorMap: Record<string, SavedObjectsTypeValidator> = {};
private readonly client: RepositoryEsClient;
private readonly _encryptionExtension?: ISavedObjectsEncryptionExtension;
private readonly _securityExtension?: ISavedObjectsSecurityExtension;
Expand Down Expand Up @@ -403,7 +404,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
* migration to fail, but it's the best we can do without devising a way to run validations
* inside the migration algorithm itself.
*/
this.validateObjectAttributes(type, migrated as SavedObjectSanitizedDoc<T>);
this.validateObjectForCreate(type, migrated as SavedObjectSanitizedDoc<T>);

const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc<T>);

Expand Down Expand Up @@ -629,7 +630,7 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
* inside the migration algorithm itself.
*/
try {
this.validateObjectAttributes(object.type, migrated);
this.validateObjectForCreate(object.type, migrated);
} catch (error) {
return {
tag: 'Left',
Expand Down Expand Up @@ -2757,25 +2758,31 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
}

/** Validate a migrated doc against the registered saved object type's schema. */
private validateObjectAttributes(type: string, doc: SavedObjectSanitizedDoc) {
const savedObjectType = this._registry.getType(type);
if (!savedObjectType?.schemas) {
private validateObjectForCreate(type: string, doc: SavedObjectSanitizedDoc) {
if (!this._registry.getType(type)) {
return;
}

const validator = new SavedObjectsTypeValidator({
logger: this._logger.get('type-validator'),
type,
validationMap: savedObjectType.schemas,
});

const validator = this.getTypeValidator(type);
try {
validator.validate(this._migrator.kibanaVersion, doc);
validator.validate(doc, this._migrator.kibanaVersion);
} catch (error) {
throw SavedObjectsErrorHelpers.createBadRequestError(error.message);
}
}

private getTypeValidator(type: string): SavedObjectsTypeValidator {
if (!this.typeValidatorMap[type]) {
const savedObjectType = this._registry.getType(type);
this.typeValidatorMap[type] = new SavedObjectsTypeValidator({
logger: this._logger.get('type-validator'),
type,
validationMap: savedObjectType!.schemas ?? {},
defaultVersion: this._migrator.kibanaVersion,
});
}
return this.typeValidatorMap[type]!;
}
Comment on lines +2773 to +2784
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just some optimization: keep a map of the per-type validators, to reuse the same instance everytime.


/** This is used when objects are created. */
private validateOriginId(type: string, objectOrOptions: { originId?: string }) {
if (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,33 +19,40 @@ type SavedObjectSanitizedDocSchema = {
[K in keyof Required<SavedObjectSanitizedDoc>]: Type<SavedObjectSanitizedDoc[K]>;
};

const baseSchema = schema.object<SavedObjectSanitizedDocSchema>({
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another optimization: using a base schema and schema.extend instead of redefining it everytime.

id: schema.string(),
type: schema.string(),
references: schema.arrayOf(
schema.object({
name: schema.string(),
type: schema.string(),
id: schema.string(),
}),
{ defaultValue: [] }
),
namespace: schema.maybe(schema.string()),
namespaces: schema.maybe(schema.arrayOf(schema.string())),
migrationVersion: schema.maybe(schema.recordOf(schema.string(), schema.string())),
coreMigrationVersion: schema.maybe(schema.string()),
typeMigrationVersion: schema.maybe(schema.string()),
updated_at: schema.maybe(schema.string()),
created_at: schema.maybe(schema.string()),
version: schema.maybe(schema.string()),
originId: schema.maybe(schema.string()),
managed: schema.maybe(schema.boolean()),
attributes: schema.maybe(schema.any()),
});

/**
* Takes a {@link SavedObjectsValidationSpec} and returns a full schema representing
* a {@link SavedObjectSanitizedDoc}, with the spec applied to the object's `attributes`.
*
* @internal
*/
export const createSavedObjectSanitizedDocSchema = (attributesSchema: SavedObjectsValidationSpec) =>
schema.object<SavedObjectSanitizedDocSchema>({
export const createSavedObjectSanitizedDocSchema = (
attributesSchema: SavedObjectsValidationSpec
) => {
return baseSchema.extends({
attributes: attributesSchema,
id: schema.string(),
type: schema.string(),
references: schema.arrayOf(
schema.object({
name: schema.string(),
type: schema.string(),
id: schema.string(),
}),
{ defaultValue: [] }
),
namespace: schema.maybe(schema.string()),
namespaces: schema.maybe(schema.arrayOf(schema.string())),
migrationVersion: schema.maybe(schema.recordOf(schema.string(), schema.string())),
coreMigrationVersion: schema.maybe(schema.string()),
typeMigrationVersion: schema.maybe(schema.string()),
updated_at: schema.maybe(schema.string()),
created_at: schema.maybe(schema.string()),
version: schema.maybe(schema.string()),
originId: schema.maybe(schema.string()),
managed: schema.maybe(schema.boolean()),
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,79 +10,162 @@ import { schema } from '@kbn/config-schema';
import { loggerMock, type MockedLogger } from '@kbn/logging-mocks';
import type {
SavedObjectSanitizedDoc,
SavedObjectsValidationSpec,
SavedObjectsValidationMap,
} from '@kbn/core-saved-objects-server';
import { SavedObjectsTypeValidator } from './validator';

const defaultVersion = '3.3.0';
const type = 'my-type';

describe('Saved Objects type validator', () => {
let validator: SavedObjectsTypeValidator;
let logger: MockedLogger;
let validationMap: SavedObjectsValidationMap;

const type = 'my-type';
const validationMap: SavedObjectsValidationMap = {
'1.0.0': schema.object({
foo: schema.string(),
}),
};

const createMockObject = (attributes: Record<string, unknown>): SavedObjectSanitizedDoc => ({
attributes,
const createMockObject = (parts: Partial<SavedObjectSanitizedDoc>): SavedObjectSanitizedDoc => ({
type,
id: 'test-id',
references: [],
type,
attributes: {},
...parts,
});

beforeEach(() => {
logger = loggerMock.create();
validator = new SavedObjectsTypeValidator({ logger, type, validationMap });
});

afterEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});

it('should do nothing if no matching validation could be found', () => {
const data = createMockObject({ foo: false });
expect(validator.validate('3.0.0', data)).toBeUndefined();
expect(logger.debug).not.toHaveBeenCalled();
});
describe('validation behavior', () => {
beforeEach(() => {
validationMap = {
'1.0.0': schema.object({
foo: schema.string(),
}),
};
validator = new SavedObjectsTypeValidator({ logger, type, validationMap, defaultVersion });
});

it('should log when a validation fails', () => {
const data = createMockObject({ foo: false });
expect(() => validator.validate('1.0.0', data)).toThrowError();
expect(logger.warn).toHaveBeenCalledTimes(1);
});
it('should log when a validation fails', () => {
const data = createMockObject({ attributes: { foo: false } });
expect(() => validator.validate(data, '1.0.0')).toThrowError();
expect(logger.warn).toHaveBeenCalledTimes(1);
});

it('should work when given valid values', () => {
const data = createMockObject({ foo: 'hi' });
expect(() => validator.validate('1.0.0', data)).not.toThrowError();
});
it('should work when given valid values', () => {
const data = createMockObject({ attributes: { foo: 'hi' } });
expect(() => validator.validate(data, '1.0.0')).not.toThrowError();
});

it('should throw an error when given invalid values', () => {
const data = createMockObject({ foo: false });
expect(() => validator.validate('1.0.0', data)).toThrowErrorMatchingInlineSnapshot(
`"[attributes.foo]: expected value of type [string] but got [boolean]"`
);
});
it('should throw an error when given invalid values', () => {
const data = createMockObject({ attributes: { foo: false } });
expect(() => validator.validate(data, '1.0.0')).toThrowErrorMatchingInlineSnapshot(
`"[attributes.foo]: expected value of type [string] but got [boolean]"`
);
});

it('should throw an error if fields other than attributes are malformed', () => {
const data = createMockObject({ foo: 'hi' });
// @ts-expect-error Intentionally malformed object
data.updated_at = false;
expect(() => validator.validate('1.0.0', data)).toThrowErrorMatchingInlineSnapshot(
`"[updated_at]: expected value of type [string] but got [boolean]"`
);
it('should throw an error if fields other than attributes are malformed', () => {
const data = createMockObject({ attributes: { foo: 'hi' } });
// @ts-expect-error Intentionally malformed object
data.updated_at = false;
expect(() => validator.validate(data, '1.0.0')).toThrowErrorMatchingInlineSnapshot(
`"[updated_at]: expected value of type [string] but got [boolean]"`
);
});

it('works when the validation map is a function', () => {
const fnValidationMap: () => SavedObjectsValidationMap = () => validationMap;
validator = new SavedObjectsTypeValidator({
logger,
type,
validationMap: fnValidationMap,
defaultVersion,
});

const data = createMockObject({ attributes: { foo: 'hi' } });
expect(() => validator.validate(data, '1.0.0')).not.toThrowError();
});
});

it('works when the validation map is a function', () => {
const fnValidationMap: () => SavedObjectsValidationMap = () => validationMap;
validator = new SavedObjectsTypeValidator({
logger,
type,
validationMap: fnValidationMap,
describe('schema selection', () => {
beforeEach(() => {
validationMap = {
'2.0.0': createStubSpec(),
'2.7.0': createStubSpec(),
'3.0.0': createStubSpec(),
'3.5.0': createStubSpec(),
'4.0.0': createStubSpec(),
'4.3.0': createStubSpec(),
};
validator = new SavedObjectsTypeValidator({ logger, type, validationMap, defaultVersion });
});

const data = createMockObject({ foo: 'hi' });
expect(() => validator.validate('1.0.0', data)).not.toThrowError();
const createStubSpec = (): jest.Mocked<SavedObjectsValidationSpec> => {
const stub = schema.object({}, { unknowns: 'allow', defaultValue: {} });
jest.spyOn(stub as any, 'getSchema');
return stub as jest.Mocked<SavedObjectsValidationSpec>;
};

const getCalledVersion = () => {
for (const [version, validation] of Object.entries(validationMap)) {
if (((validation as any).getSchema as jest.MockedFn<any>).mock.calls.length > 0) {
return version;
}
}
return undefined;
};

it('should use the correct schema when specifying the version', () => {
let data = createMockObject({ typeMigrationVersion: '2.2.0' });
validator.validate(data, '3.2.0');
expect(getCalledVersion()).toEqual('3.0.0');

jest.clearAllMocks();

data = createMockObject({ typeMigrationVersion: '3.5.0' });
validator.validate(data, '4.5.0');
expect(getCalledVersion()).toEqual('4.3.0');
});

it('should use the correct schema for documents with typeMigrationVersion', () => {
let data = createMockObject({ typeMigrationVersion: '3.2.0' });
validator.validate(data);
expect(getCalledVersion()).toEqual('3.0.0');

jest.clearAllMocks();

data = createMockObject({ typeMigrationVersion: '3.5.0' });
validator.validate(data);
expect(getCalledVersion()).toEqual('3.5.0');
});

it('should use the correct schema for documents with migrationVersion', () => {
let data = createMockObject({
migrationVersion: {
[type]: '4.6.0',
},
});
validator.validate(data);
expect(getCalledVersion()).toEqual('4.3.0');

jest.clearAllMocks();

data = createMockObject({
migrationVersion: {
[type]: '4.0.0',
},
});
validator.validate(data);
expect(getCalledVersion()).toEqual('4.0.0');
});

it('should use the correct schema for documents without a version specified', () => {
const data = createMockObject({});
validator.validate(data);
expect(getCalledVersion()).toEqual('3.0.0');
});
});
});
Loading