diff --git a/ui/app/components/generated-item.js b/ui/app/components/generated-item.js index 9b82a7e8ad99..06f17ce8b234 100644 --- a/ui/app/components/generated-item.js +++ b/ui/app/components/generated-item.js @@ -67,16 +67,16 @@ export default Component.extend({ actions: { onKeyUp(name, value) { this.model.set(name, value); - if (this.model.validations) { + if (this.model.validate) { // Set validation error message for updated attribute - this.model.validations.attrs[name] && this.model.validations.attrs[name].isValid - ? set(this.validationMessages, name, '') - : set(this.validationMessages, name, this.model.validations.attrs[name].message); - + const { isValid, state } = this.model.validate(); + if (state[name]) { + state[name].isValid + ? set(this.validationMessages, name, '') + : set(this.validationMessages, name, state[name].errors.join('. ')); + } // Set form button state - this.model.validate().then(({ validations }) => { - this.set('isFormInvalid', !validations.isValid); - }); + this.set('isFormInvalid', !isValid); } else { this.set('isFormInvalid', false); } diff --git a/ui/app/components/mount-backend-form.js b/ui/app/components/mount-backend-form.js index 3f874362af2a..481fac75b856 100644 --- a/ui/app/components/mount-backend-form.js +++ b/ui/app/components/mount-backend-form.js @@ -45,7 +45,7 @@ export default Component.extend({ showEnable: false, - // cp-validation related properties + // validation related properties validationMessages: null, isFormInvalid: false, @@ -166,27 +166,24 @@ export default Component.extend({ actions: { onKeyUp(name, value) { + this.mountModel.set(name, value); + const { + isValid, + state: { path, maxVersions }, + } = this.mountModel.validate(); // validate path if (name === 'path') { - this.mountModel.set('path', value); - this.mountModel.validations.attrs.path.isValid + path.isValid ? set(this.validationMessages, 'path', '') - : set(this.validationMessages, 'path', this.mountModel.validations.attrs.path.message); + : set(this.validationMessages, 'path', path.errors.join('. ')); } // check maxVersions is a number if (name === 'maxVersions') { - this.mountModel.set('maxVersions', value); - this.mountModel.validations.attrs.maxVersions.isValid + maxVersions.isValid ? set(this.validationMessages, 'maxVersions', '') - : set( - this.validationMessages, - 'maxVersions', - this.mountModel.validations.attrs.maxVersions.message - ); + : set(this.validationMessages, 'maxVersions', maxVersions.errors.join('. ')); } - this.mountModel.validate().then(({ validations }) => { - this.set('isFormInvalid', !validations.isValid); - }); + this.set('isFormInvalid', !isValid); }, onTypeChange(path, value) { if (path === 'type') { diff --git a/ui/app/components/secret-edit-metadata.js b/ui/app/components/secret-edit-metadata.js index 88b09dfb20f4..09fe2750a1d6 100644 --- a/ui/app/components/secret-edit-metadata.js +++ b/ui/app/components/secret-edit-metadata.js @@ -54,6 +54,7 @@ export default class SecretEditMetadata extends Component { if (value) { if (name === 'customMetadata') { // cp validations won't work on an object so performing validations here + // JLR TODO: review this and incorporate into model-validations system /* eslint-disable no-useless-escape */ let regex = /^[^\\]+$/g; // looking for a backward slash value.match(regex) @@ -62,9 +63,12 @@ export default class SecretEditMetadata extends Component { } if (name === 'maxVersions') { this.args.model.maxVersions = value; - this.args.model.validations.attrs.maxVersions.isValid + const { + state: { maxVersions }, + } = this.args.model.validate(); + maxVersions.isValid ? set(this.validationMessages, name, '') - : set(this.validationMessages, name, this.args.model.validations.attrs.maxVersions.message); + : set(this.validationMessages, name, maxVersions.errors.join('. ')); } } diff --git a/ui/app/decorators/model-validations.js b/ui/app/decorators/model-validations.js new file mode 100644 index 000000000000..18aad5a554db --- /dev/null +++ b/ui/app/decorators/model-validations.js @@ -0,0 +1,58 @@ +/* eslint-disable no-console */ +import validators from 'vault/utils/validators'; + +export function withModelValidations(validations) { + return function decorator(SuperClass) { + return class ModelValidations extends SuperClass { + static _validations; + + constructor() { + super(...arguments); + if (!validations || typeof validations !== 'object') { + throw new Error('Validations object must be provided to constructor for setup'); + } + this._validations = validations; + } + + validate() { + let isValid = true; + const state = {}; + + for (const key in this._validations) { + const rules = this._validations[key]; + + if (!Array.isArray(rules)) { + console.error( + `Must provide validations as an array for property "${key}" on ${this.modelName} model` + ); + continue; + } + + state[key] = { errors: [] }; + + for (const rule of rules) { + const { type, options, message } = rule; + if (!validators[type]) { + console.error( + `Validator type: "${type}" not found. Available validators: ${Object.keys(validators).join( + ', ' + )}` + ); + continue; + } + if (!validators[type](this[key], options)) { + // consider setting a prop like validationErrors directly on the model + // for now return an errors object + state[key].errors.push(message); + if (isValid) { + isValid = false; + } + } + } + state[key].isValid = !state[key].errors.length; + } + return { isValid, state }; + } + }; + }; +} diff --git a/ui/app/models/auth-method.js b/ui/app/models/auth-method.js index de82302c8509..3ba5eb335194 100644 --- a/ui/app/models/auth-method.js +++ b/ui/app/models/auth-method.js @@ -1,22 +1,22 @@ import Model, { hasMany, attr } from '@ember-data/model'; -import { alias } from '@ember/object/computed'; -import { computed } from '@ember/object'; +import { alias } from '@ember/object/computed'; // eslint-disable-line +import { computed } from '@ember/object'; // eslint-disable-line import { fragment } from 'ember-data-model-fragments/attributes'; import fieldToAttrs, { expandAttributeMeta } from 'vault/utils/field-to-attrs'; import { memberAction } from 'ember-api-actions'; -import { validator, buildValidations } from 'ember-cp-validations'; - import apiPath from 'vault/utils/api-path'; import attachCapabilities from 'vault/lib/attach-capabilities'; +import { withModelValidations } from 'vault/decorators/model-validations'; -const Validations = buildValidations({ - path: validator('presence', { - presence: true, - message: "Path can't be blank.", - }), -}); +const validations = { + path: [{ type: 'presence', message: "Path can't be blank." }], +}; -let ModelExport = Model.extend(Validations, { +// unsure if ember-api-actions will work on native JS class model +// for now create class to use validations and then use classic extend pattern +@withModelValidations(validations) +class AuthMethodModel extends Model {} +const ModelExport = AuthMethodModel.extend({ authConfigs: hasMany('auth-config', { polymorphic: true, inverse: 'backend', async: false }), path: attr('string'), accessor: attr('string'), diff --git a/ui/app/models/secret-engine.js b/ui/app/models/secret-engine.js index 45bb60df192a..b30b01b3d2af 100644 --- a/ui/app/models/secret-engine.js +++ b/ui/app/models/secret-engine.js @@ -1,34 +1,25 @@ import Model, { attr } from '@ember-data/model'; -import { computed } from '@ember/object'; -import { equal } from '@ember/object/computed'; +import { computed } from '@ember/object'; // eslint-disable-line +import { equal } from '@ember/object/computed'; // eslint-disable-line import { fragment } from 'ember-data-model-fragments/attributes'; import fieldToAttrs, { expandAttributeMeta } from 'vault/utils/field-to-attrs'; -import { validator, buildValidations } from 'ember-cp-validations'; +import { withModelValidations } from 'vault/decorators/model-validations'; // identity will be managed separately and the inclusion // of the system backend is an implementation detail const LIST_EXCLUDED_BACKENDS = ['system', 'identity']; -const Validations = buildValidations({ - path: validator('presence', { - presence: true, - message: "Path can't be blank.", - }), +const validations = { + path: [{ type: 'presence', message: "Path can't be blank." }], maxVersions: [ - validator('number', { - allowString: true, - integer: true, - message: 'Maximum versions must be a number.', - }), - validator('length', { - min: 1, - max: 16, - message: 'You cannot go over 16 characters.', - }), + { type: 'number', options: { asString: true }, message: 'Maximum versions must be a number.' }, + { type: 'length', options: { min: 1, max: 16 }, message: 'You cannot go over 16 characters.' }, ], -}); +}; -export default Model.extend(Validations, { +@withModelValidations(validations) +class SecretEngineModel extends Model {} +export default SecretEngineModel.extend({ path: attr('string'), accessor: attr('string'), name: attr('string'), diff --git a/ui/app/models/secret-v2.js b/ui/app/models/secret-v2.js index f4db1443c186..1f95d6e3d224 100644 --- a/ui/app/models/secret-v2.js +++ b/ui/app/models/secret-v2.js @@ -1,27 +1,21 @@ import Model, { belongsTo, hasMany, attr } from '@ember-data/model'; -import { computed } from '@ember/object'; -import { alias } from '@ember/object/computed'; +import { computed } from '@ember/object'; // eslint-disable-line +import { alias } from '@ember/object/computed'; // eslint-disable-line import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; import KeyMixin from 'vault/mixins/key-mixin'; import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; -import { validator, buildValidations } from 'ember-cp-validations'; +import { withModelValidations } from 'vault/decorators/model-validations'; -const Validations = buildValidations({ +const validations = { maxVersions: [ - validator('number', { - allowString: true, - integer: true, - message: 'Maximum versions must be a number.', - }), - validator('length', { - min: 1, - max: 16, - message: 'You cannot go over 16 characters.', - }), + { type: 'number', options: { asString: true }, message: 'Maximum versions must be a number.' }, + { type: 'length', options: { min: 1, max: 16 }, message: 'You cannot go over 16 characters.' }, ], -}); +}; -export default Model.extend(KeyMixin, Validations, { +@withModelValidations(validations) +class SecretV2Model extends Model {} +export default SecretV2Model.extend(KeyMixin, { failedServerRead: attr('boolean'), engine: belongsTo('secret-engine', { async: false }), engineId: attr('string'), diff --git a/ui/app/services/path-help.js b/ui/app/services/path-help.js index cd2f346731c0..8e51da5a5b71 100644 --- a/ui/app/services/path-help.js +++ b/ui/app/services/path-help.js @@ -14,7 +14,7 @@ import { resolve, reject } from 'rsvp'; import { debug } from '@ember/debug'; import { dasherize, capitalize } from '@ember/string'; import { singularize } from 'ember-inflector'; -import buildValidations from 'vault/utils/build-api-validators'; +import { withModelValidations } from 'vault/decorators/model-validations'; import generatedItemAdapter from 'vault/adapters/generated-item-list'; export function sanitizePath(path) { @@ -36,6 +36,7 @@ export default Service.extend({ getNewModel(modelType, backend, apiPath, itemType) { let owner = getOwner(this); const modelName = `model:${modelType}`; + const modelFactory = owner.factoryFor(modelName); let newModel, helpUrl; // if we have a factory, we need to take the existing model into account @@ -298,8 +299,17 @@ export default Service.extend({ // Build and add validations on model // NOTE: For initial phase, initialize validations only for user pass auth if (backend === 'userpass') { - let validations = buildValidations(fieldGroups); - newModel = newModel.extend(validations); + const validations = fieldGroups.reduce((obj, element) => { + if (element.default) { + element.default.forEach((v) => { + obj[v.name] = [{ type: 'presence', message: `${v.name} can't be black` }]; + }); + } + return obj; + }, {}); + @withModelValidations(validations) + class GeneratedItemModel extends newModel {} + newModel = GeneratedItemModel; } } } catch (err) { diff --git a/ui/app/utils/build-api-validators.js b/ui/app/utils/build-api-validators.js deleted file mode 100644 index 6b3d846341b2..000000000000 --- a/ui/app/utils/build-api-validators.js +++ /dev/null @@ -1,30 +0,0 @@ -import { validator, buildValidations } from 'ember-cp-validations'; - -/** - * Add validation on dynamic form fields generated via open api spec - * For fields grouped under default category, add the require/presence validator - * @param {Array} fieldGroups - * fieldGroups param example: - * [ { default: [{name: 'username'}, {name: 'password'}] }, - * { Tokens: [{name: 'tokenBoundCidrs'}] } - * ] - * @returns ember cp validation class - */ -export default function initValidations(fieldGroups) { - let validators = {}; - fieldGroups.forEach((element) => { - if (element.default) { - element.default.forEach((v) => { - validators[v.name] = createPresenceValidator(v.name); - }); - } - }); - return buildValidations(validators); -} - -export const createPresenceValidator = function (label) { - return validator('presence', { - presence: true, - message: `${label} can't be blank.`, - }); -}; diff --git a/ui/app/utils/validators.js b/ui/app/utils/validators.js new file mode 100644 index 000000000000..8f2aed97857e --- /dev/null +++ b/ui/app/utils/validators.js @@ -0,0 +1,23 @@ +import { isPresent } from '@ember/utils'; + +export const presence = (value) => isPresent(value); + +export const length = (value, { nullable = false, min, max } = {}) => { + let isValid = nullable; + if (typeof value === 'string') { + const underMin = min && value.length < min; + const overMax = max && value.length > max; + isValid = underMin || overMax ? false : true; + } + return isValid; +}; + +export const number = (value, { nullable = false, asString } = {}) => { + if (!value) return nullable; + if (typeof value === 'string' && !asString) { + return false; + } + return !isNaN(value); +}; + +export default { presence, length, number }; diff --git a/ui/package.json b/ui/package.json index 228765b7ae69..8115c1047c1e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -115,7 +115,6 @@ "ember-composable-helpers": "^4.3.0", "ember-concurrency": "^2.1.2", "ember-copy": "2.0.1", - "ember-cp-validations": "^4.0.0-beta.12", "ember-d3": "^0.5.1", "ember-data": "~3.28.6", "ember-data-model-fragments": "5.0.0-beta.5", diff --git a/ui/tests/acceptance/secrets/backend/kv/secret-test.js b/ui/tests/acceptance/secrets/backend/kv/secret-test.js index daa378e45f90..4f94094c5add 100644 --- a/ui/tests/acceptance/secrets/backend/kv/secret-test.js +++ b/ui/tests/acceptance/secrets/backend/kv/secret-test.js @@ -213,16 +213,11 @@ module('Acceptance | secrets/secret/create', function (hooks) { await editPage.toggleMetadata(); await settled(); - /* TODO - * commenting out for now until ember-cp-validations is updated or removed - * throws an error when attempting to use isHTMLSafe which is imported from @ember/string in the cp-validations code - * in ember 3.28 the import changed to @ember/template - */ - // await typeIn('[data-test-input="maxVersions"]', 'abc'); - // assert - // .dom('[data-test-input="maxVersions"]') - // .hasClass('has-error-border', 'shows border error on input with error'); - // assert.dom('[data-test-secret-save]').isDisabled('Save button is disabled'); + await typeIn('[data-test-input="maxVersions"]', 'abc'); + assert + .dom('[data-test-input="maxVersions"]') + .hasClass('has-error-border', 'shows border error on input with error'); + assert.dom('[data-test-secret-save]').isDisabled('Save button is disabled'); await fillIn('[data-test-input="maxVersions"]', 20); // fillIn replaces the text, whereas typeIn only adds to it. await triggerKeyEvent('[data-test-input="maxVersions"]', 'keyup', 65); await editPage.path(secretPath); diff --git a/ui/tests/unit/decorators/model-validations-test.js b/ui/tests/unit/decorators/model-validations-test.js new file mode 100644 index 000000000000..e036a31bf27b --- /dev/null +++ b/ui/tests/unit/decorators/model-validations-test.js @@ -0,0 +1,84 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { withModelValidations } from 'vault/decorators/model-validations'; +import validators from 'vault/utils/validators'; +import sinon from 'sinon'; +import Model from '@ember-data/model'; + +// create class using decorator +const createClass = (validations) => { + @withModelValidations(validations) + class Foo extends Model {} + const foo = Foo.extend({ + modelName: 'bar', + foo: null, + }); + return new foo(); +}; + +module('Unit | Decorators | ModelValidations', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.spy = sinon.spy(console, 'error'); + }); + hooks.afterEach(function () { + this.spy.restore(); + }); + + test('it should throw error when validations object is not provided', function (assert) { + assert.expect(1); + + try { + createClass(); + } catch (e) { + assert.equal(e.message, 'Validations object must be provided to constructor for setup'); + } + }); + + test('it should log error to console when validations are not passed as array', function (assert) { + const validations = { + foo: { type: 'presence', message: 'Foo is required' }, + }; + const fooClass = createClass(validations); + fooClass.validate(); + const message = 'Must provide validations as an array for property "foo" on bar model'; + assert.ok(this.spy.calledWith(message)); + }); + + test('it should log error for incorrect validator type', function (assert) { + const validations = { + foo: [{ type: 'bar', message: 'Foo is bar' }], + }; + const fooClass = createClass(validations); + fooClass.validate(); + const message = `Validator type: "bar" not found. Available validators: ${Object.keys(validators).join( + ', ' + )}`; + assert.ok(this.spy.calledWith(message)); + }); + + test('it should validate', function (assert) { + const message = 'This field is required'; + const validations = { + foo: [{ type: 'presence', message }], + }; + const fooClass = createClass(validations); + const v1 = fooClass.validate(); + assert.false(v1.isValid, 'isValid state is correct when errors exist'); + assert.deepEqual( + v1.state, + { foo: { isValid: false, errors: [message] } }, + 'Correct state returned when property is invalid' + ); + + fooClass.foo = true; + const v2 = fooClass.validate(); + assert.true(v2.isValid, 'isValid state is correct when no errors exist'); + assert.deepEqual( + v2.state, + { foo: { isValid: true, errors: [] } }, + 'Correct state returned when property is valid' + ); + }); +}); diff --git a/ui/tests/unit/utils/validators-test.js b/ui/tests/unit/utils/validators-test.js new file mode 100644 index 000000000000..d18b150e203f --- /dev/null +++ b/ui/tests/unit/utils/validators-test.js @@ -0,0 +1,51 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import validators from 'vault/utils/validators'; + +module('Unit | Util | validators', function (hooks) { + setupTest(hooks); + + test('it should validate presence', function (assert) { + let isValid = validators.presence(null); + assert.false(isValid); + isValid = validators.presence(true); + assert.true(isValid); + }); + + test('it should validate length', function (assert) { + let isValid; + const options = { nullable: true, min: 3, max: 5 }; + const check = (prop) => (isValid = validators.length(prop, options)); + check(null); + assert.true(isValid, 'Valid when nullable is true'); + options.nullable = false; + check(null); + assert.false(isValid, 'Invalid when nullable is false'); + check('12'); + assert.false(isValid, 'Invalid when not min length'); + check('123456'); + assert.false(isValid, 'Invalid when over max length'); + check('1234'); + assert.true(isValid, 'Valid when in between min and max length'); + }); + + test('it should validate number', function (assert) { + let isValid; + const options = { nullable: true, asString: false }; + const check = (prop) => (isValid = validators.number(prop, options)); + check(null); + assert.true(isValid, 'Valid when nullable is true'); + options.nullable = false; + check(null); + assert.false(isValid, 'Invalid when nullable is false'); + check('9'); + assert.false(isValid, 'Invalid for string when asString is false'); + check(9); + assert.true(isValid, 'Valid for number'); + options.asString = true; + check('9'); + assert.true(isValid, 'Valid for number as string'); + check('foo'); + assert.false(isValid, 'Invalid for string that is not a number'); + }); +}); diff --git a/ui/yarn.lock b/ui/yarn.lock index 828d92154aae..9109577e44d7 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -10508,7 +10508,7 @@ ember-cli-babel-plugin-helpers@^1.0.0, ember-cli-babel-plugin-helpers@^1.1.0, em resolved "https://registry.yarnpkg.com/ember-cli-babel-plugin-helpers/-/ember-cli-babel-plugin-helpers-1.1.1.tgz#5016b80cdef37036c4282eef2d863e1d73576879" integrity sha512-sKvOiPNHr5F/60NLd7SFzMpYPte/nnGkq/tMIfXejfKHIhaiIkYFqX8Z9UFTKWLLn+V7NOaby6niNPZUdvKCRw== -ember-cli-babel@^6.0.0-beta.4, ember-cli-babel@^6.16.0, ember-cli-babel@^6.6.0, ember-cli-babel@^6.9.0, ember-cli-babel@^6.9.2: +ember-cli-babel@^6.0.0-beta.4, ember-cli-babel@^6.16.0, ember-cli-babel@^6.6.0, ember-cli-babel@^6.9.0: version "6.18.0" resolved "https://registry.yarnpkg.com/ember-cli-babel/-/ember-cli-babel-6.18.0.tgz#3f6435fd275172edeff2b634ee7b29ce74318957" integrity sha512-7ceC8joNYxY2wES16iIBlbPSxwKDBhYwC8drU3ZEvuPDMwVv1KzxCNu1fvxyFEBWhwaRNTUxSCsEVoTd9nosGA== @@ -11139,15 +11139,6 @@ ember-copy@2.0.1: dependencies: ember-cli-babel "^7.22.1" -ember-cp-validations@^4.0.0-beta.12: - version "4.0.0-beta.12" - resolved "https://registry.yarnpkg.com/ember-cp-validations/-/ember-cp-validations-4.0.0-beta.12.tgz#27c7e79e36194b8bb55c5c97421b2671f0abf58c" - integrity sha512-GHOJm2pjan4gOOBFecs7PdEf86vnWgTPCtfqwyqf3wlN0CihYf+mHZhjnnN6R1fnPDn+qLwByl6gJq7il115dw== - dependencies: - ember-cli-babel "^7.1.2" - ember-require-module "^0.3.0" - ember-validators "^3.0.1" - ember-d3@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/ember-d3/-/ember-d3-0.5.1.tgz#b23ce145863f082b5e73d25d9a43a0f1d9e9f412" @@ -11424,13 +11415,6 @@ ember-qunit@^5.1.5: silent-error "^1.1.1" validate-peer-dependencies "^1.2.0" -ember-require-module@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/ember-require-module/-/ember-require-module-0.3.0.tgz#65aff7908b5b846467e4526594d33cfe0c23456b" - integrity sha512-rYN4YoWbR9VlJISSmx0ZcYZOgMcXZLGR7kdvp3zDerjIvYmHm/3p+K56fEAYmJILA6W4F+cBe41Tq2HuQAZizA== - dependencies: - ember-cli-babel "^6.9.2" - ember-resolver@^8.0.3: version "8.0.3" resolved "https://registry.yarnpkg.com/ember-resolver/-/ember-resolver-8.0.3.tgz#40f243aa58281bf195c695fe84a6b291e204690a" @@ -11651,14 +11635,6 @@ ember-truth-helpers@3.0.0, "ember-truth-helpers@^2.1.0 || ^3.0.0": dependencies: ember-cli-babel "^7.22.1" -ember-validators@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/ember-validators/-/ember-validators-3.0.1.tgz#9e0f7ed4ce6817aa05f7d46e95a0267c03f1f043" - integrity sha512-GbvvECDG9N7U+4LXxPWNgiSnGbOzgvGBIxtS4kw2uyEIy7kymtgszhpSnm8lGMKYnhCKBqFingh8qnVKlCi0lg== - dependencies: - ember-cli-babel "^6.9.2" - ember-require-module "^0.3.0" - ember-wormhole@0.6.0, ember-wormhole@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/ember-wormhole/-/ember-wormhole-0.6.0.tgz#1f9143aa05c0f0abdf14a97ff22520ebaf85eca0"