diff --git a/ui/app/adapters/generated-item-list.js b/ui/app/adapters/generated-item-list.js index 5c5d3f2e9145..417d77864796 100644 --- a/ui/app/adapters/generated-item-list.js +++ b/ui/app/adapters/generated-item-list.js @@ -6,42 +6,100 @@ import ApplicationAdapter from './application'; import { task } from 'ember-concurrency'; import { service } from '@ember/service'; +import { sanitizePath } from 'core/utils/sanitize-path'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; +import { tracked } from '@glimmer/tracking'; -export default ApplicationAdapter.extend({ - store: service(), - namespace: 'v1', - urlForItem() {}, - dynamicApiPath: '', +export default class GeneratedItemListAdapter extends ApplicationAdapter { + @service store; + namespace = 'v1'; - getDynamicApiPath: task(function* (id) { - // TODO: remove yield at some point. - const result = yield this.store.peekRecord('auth-method', id); - this.dynamicApiPath = result.apiPath; - return; - }), + // these items are set within getNewAdapter in path-help service + @tracked apiPath = ''; + paths = {}; - fetchByQuery: task(function* (store, query, isList) { + getDynamicApiPath(id) { + const result = this.store.peekRecord('auth-method', id); + this.apiPath = result.apiPath; + return result.apiPath; + } + + fetchByQuery = task(async (store, query, isList) => { const { id } = query; - const data = {}; + const payload = {}; if (isList) { - data.list = true; - yield this.getDynamicApiPath.perform(id); + payload.list = true; } + const path = isList ? this.getDynamicApiPath(id) : ''; - return this.ajax(this.urlForItem(id, isList, this.dynamicApiPath), 'GET', { data }).then((resp) => { - const data = { - id, - method: id, - }; - return { ...resp, ...data }; - }); - }), + const resp = await this.ajax(this.urlForItem(id, isList, path), 'GET', { data: payload }); + const data = { + id, + method: id, + }; + return { ...resp, ...data }; + }); query(store, type, query) { return this.fetchByQuery.perform(store, query, true); - }, + } queryRecord(store, type, query) { return this.fetchByQuery.perform(store, query); - }, -}); + } + + urlForItem(id, isList, dynamicApiPath) { + const itemType = sanitizePath(this.paths.getPath); + let url; + id = encodePath(id); + // the apiPath changes when you switch between routes but the apiPath variable does not unless the model is reloaded + // overwrite apiPath if dynamicApiPath exist. + // dynamicApiPath comes from the model->adapter + let apiPath = this.apiPath; + if (dynamicApiPath) { + apiPath = dynamicApiPath; + } + // isList indicates whether we are viewing the list page + // of a top-level item such as userpass + if (isList) { + url = `${this.buildURL()}/${apiPath}${itemType}/`; + } else { + // build the URL for the show page of a nested item + // such as a userpass group + url = `${this.buildURL()}/${apiPath}${itemType}/${id}`; + } + + return url; + } + + urlForQueryRecord(id, modelName) { + return this.urlForItem(id, modelName); + } + + urlForUpdateRecord(id) { + const itemType = this.paths.createPath.slice(1, this.paths.createPath.indexOf('{') - 1); + return `${this.buildURL()}/${this.apiPath}${itemType}/${id}`; + } + + urlForCreateRecord(modelType, snapshot) { + const id = snapshot.record.mutableId; // computed property that returns either id or private settable _id value + const path = this.paths.createPath.slice(1, this.paths.createPath.indexOf('{') - 1); + return `${this.buildURL()}/${this.apiPath}${path}/${id}`; + } + + urlForDeleteRecord(id) { + const path = this.paths.deletePath.slice(1, this.paths.deletePath.indexOf('{') - 1); + return `${this.buildURL()}/${this.apiPath}${path}/${id}`; + } + + createRecord(store, type, snapshot) { + return super.createRecord(...arguments).then((response) => { + // if the server does not return an id and one has not been set on the model we need to set it manually from the mutableId value + if (!response?.id && !snapshot.record.id) { + snapshot.record.id = snapshot.record.mutableId; + snapshot.id = snapshot.record.id; + } + return response; + }); + } +} diff --git a/ui/app/models/generated-item.js b/ui/app/models/generated-item.js new file mode 100644 index 000000000000..5650f3f5d647 --- /dev/null +++ b/ui/app/models/generated-item.js @@ -0,0 +1,35 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Model from '@ember-data/model'; + +// This model is used for OpenApi-generated models in path-help service's getNewModel method +export default class GeneratedItemModel extends Model { + allFields = []; + + get fieldGroups() { + const groups = { + default: [], + }; + const fieldGroups = []; + this.constructor.eachAttribute((name, attr) => { + // if the attr comes in with a fieldGroup from OpenAPI, + if (attr.options.fieldGroup) { + if (groups[attr.options.fieldGroup]) { + groups[attr.options.fieldGroup].push(attr); + } else { + groups[attr.options.fieldGroup] = [attr]; + } + } else { + // otherwise just add that attr to the default group + groups.default.push(attr); + } + }); + for (const group in groups) { + fieldGroups.push({ [group]: groups[group] }); + } + return fieldGroups; + } +} diff --git a/ui/app/services/path-help.js b/ui/app/services/path-help.js index 07fc64416b01..1dec92f2c720 100644 --- a/ui/app/services/path-help.js +++ b/ui/app/services/path-help.js @@ -8,63 +8,112 @@ shape of data at a specific path to hydrate a model with attrs it has less (or no) information about. */ -import Model from '@ember-data/model'; +import Model, { attr } from '@ember-data/model'; import Service from '@ember/service'; -import { encodePath } from 'vault/utils/path-encoding-helpers'; import { getOwner } from '@ember/owner'; -import { expandOpenApiProps, combineAttributes } from 'vault/utils/openapi-to-attrs'; -import fieldToAttrs from 'vault/utils/field-to-attrs'; import { resolve, reject } from 'rsvp'; import { debug } from '@ember/debug'; import { capitalize } from '@ember/string'; import { computed } from '@ember/object'; // eslint-disable-line -import { withModelValidations } from 'vault/decorators/model-validations'; -import generatedItemAdapter from 'vault/adapters/generated-item-list'; -import { sanitizePath } from 'core/utils/sanitize-path'; import { filterPathsByItemType, pathToHelpUrlSegment, reducePathsByPathName, getHelpUrlForModel, + combineOpenApiAttrs, + expandOpenApiProps, } from 'vault/utils/openapi-helpers'; -import { isPresent } from '@ember/utils'; +import GeneratedItemModel from 'vault/models/generated-item'; +import GeneratedItemListAdapter from 'vault/adapters/generated-item-list'; -export default Service.extend({ - attrs: null, - dynamicApiPath: '', +export default class PathHelpService extends Service { ajax(url, options = {}) { const appAdapter = getOwner(this).lookup(`adapter:application`); const { data } = options; return appAdapter.ajax(url, 'GET', { data, }); - }, + } + + /** + * Registers new ModelClass at specified model type, and busts cache + */ + _registerModel(owner, NewKlass, modelType, isNew = false) { + const store = owner.lookup('service:store'); + // bust cache in ember's registry + if (!isNew) { + owner.unregister('model:' + modelType); + } + owner.register('model:' + modelType, NewKlass); + + // bust cache in EmberData's model lookup + delete store._modelFactoryCache[modelType]; + + // bust cache in schema service + const schemas = store.getSchemaDefinitionService?.(); + if (schemas) { + delete schemas._relationshipsDefCache[modelType]; + delete schemas._attributesDefCache[modelType]; + } + } + + /** + * upgradeModelSchema takes an existing ModelClass and hydrates it with the passed attributes + * @param {ModelClass} Klass model class retrieved with store.modelFor(modelType) + * @param {Attribute[]} attrs array of attributes {name, type, options} + * @returns new ModelClass extended from passed one, with the passed attributes added + */ + _upgradeModelSchema(Klass, attrs, newFields) { + // extending the class will ensure that static schema lookups regenerate + const NewKlass = class extends Klass {}; + + for (const { name, type, options } of attrs) { + const decorator = attr(type, options); + const descriptor = decorator(NewKlass.prototype, name, {}); + Object.defineProperty(NewKlass.prototype, name, descriptor); + } + + // newFields is used in combineFieldGroups within various models + if (newFields) { + NewKlass.prototype.newFields = newFields; + } + + // Ensure this class doesn't get re-hydrated + NewKlass.merged = true; + + return NewKlass; + } /** * hydrateModel instantiates models which use OpenAPI partially * @param {string} modelType path for model, eg pki/role * @param {string} backend path, which will be used for the generated helpUrl - * @returns void - as side effect, registers model via registerNewModelWithProps + * @returns void - as side effect, re-registers model via upgradeModelSchema */ - hydrateModel(modelType, backend) { + async hydrateModel(modelType, backend) { const owner = getOwner(this); - const modelName = `model:${modelType}`; - - const modelFactory = owner.factoryFor(modelName); const helpUrl = getHelpUrlForModel(modelType, backend); + const store = owner.lookup('service:store'); + const Klass = store.modelFor(modelType); - if (!modelFactory) { - throw new Error(`modelFactory for ${modelType} not found -- use getNewModel instead.`); - } - - debug(`Model factory found for ${modelType}`); - const newModel = modelFactory.class; - if (newModel.merged || !helpUrl) { + if (Klass?.merged || !helpUrl) { + // if the model is already merged, we don't need to do anything return resolve(); } - return this.registerNewModelWithProps(helpUrl, backend, newModel, modelName); - }, + debug(`Hydrating model ${modelType} at backend ${backend}`); + + // fetch props from openAPI + const props = await this.getProps(helpUrl); + // combine existing attributes with openAPI data + const { attrs, newFields } = combineOpenApiAttrs(Klass.attributes, props); + debug(`${modelType} has ${newFields.length} new fields: ${newFields.join(', ')}`); + + // hydrate model + const HydratedKlass = this._upgradeModelSchema(Klass, attrs, newFields); + + this._registerModel(owner, HydratedKlass, modelType); + } /** * getNewModel instantiates models which use OpenAPI to generate the model fully @@ -72,7 +121,7 @@ export default Service.extend({ * @param {string} backend * @param {string} apiPath this method will call getPaths and build submodels for item types * @param {*} itemType (optional) used in getPaths for additional models - * @returns void - as side effect, registers model via registerNewModelWithProps + * @returns void - as side effect, registers model via registerNewModelWithAttrs */ getNewModel(modelType, backend, apiPath, itemType) { const owner = getOwner(this); @@ -117,13 +166,13 @@ export default Service.extend({ const helpUrl = `/v1/${apiPath}${path.slice(1)}?help=true`; pathInfo.paths = paths; newModel = newModel.extend({ paths: pathInfo }); - return this.registerNewModelWithProps(helpUrl, backend, newModel, modelName); + return this.registerNewModelWithAttrs(helpUrl, modelType); }) .catch((err) => { // TODO: we should handle the error better here console.error(err); // eslint-disable-line }); - }, + } /** * getPaths is used to fetch all the openAPI paths available for an auth method, @@ -152,7 +201,7 @@ export default Service.extend({ itemID, }); }); - }, + } // Makes a call to grab the OpenAPI document. // Returns relevant information from OpenAPI @@ -197,17 +246,16 @@ export default Service.extend({ } else if (schema.properties) { props = schema.properties; } - // put url params (e.g. {name}, {role}) - // at the front of the props list + // put url params (e.g. {name}, {role}) at the front of the props list const newProps = { ...paramProp, ...props }; return expandOpenApiProps(newProps); }); - }, + } getNewAdapter(pathInfo, itemType) { // we need list and create paths to set the correct urls for actions const paths = filterPathsByItemType(pathInfo, itemType); - let { apiPath } = pathInfo; + const { apiPath } = pathInfo; const getPath = paths.find((path) => path.operations.includes('get')); // the action might be "Generate" or something like that so we'll grab the first post endpoint if there @@ -216,140 +264,28 @@ export default Service.extend({ const createPath = paths.find((path) => path.action === 'Create' || path.operations.includes('post')); const deletePath = paths.find((path) => path.operations.includes('delete')); - return generatedItemAdapter.extend({ - urlForItem(id, isList, dynamicApiPath) { - const itemType = sanitizePath(getPath.path); - let url; - id = encodePath(id); - // the apiPath changes when you switch between routes but the apiPath variable does not unless the model is reloaded - // overwrite apiPath if dynamicApiPath exist. - // dynamicApiPath comes from the model->adapter - if (dynamicApiPath) { - apiPath = dynamicApiPath; - } - // isList indicates whether we are viewing the list page - // of a top-level item such as userpass - if (isList) { - url = `${this.buildURL()}/${apiPath}${itemType}/`; - } else { - // build the URL for the show page of a nested item - // such as a userpass group - url = `${this.buildURL()}/${apiPath}${itemType}/${id}`; - } + return class NewAdapter extends GeneratedItemListAdapter { + apiPath = apiPath; - return url; - }, - - urlForQueryRecord(id, modelName) { - return this.urlForItem(id, modelName); - }, - - urlForUpdateRecord(id) { - const itemType = createPath.path.slice(1, createPath.path.indexOf('{') - 1); - return `${this.buildURL()}/${apiPath}${itemType}/${id}`; - }, - - urlForCreateRecord(modelType, snapshot) { - const id = snapshot.record.mutableId; // computed property that returns either id or private settable _id value - const path = createPath.path.slice(1, createPath.path.indexOf('{') - 1); - return `${this.buildURL()}/${apiPath}${path}/${id}`; - }, - - urlForDeleteRecord(id) { - const path = deletePath.path.slice(1, deletePath.path.indexOf('{') - 1); - return `${this.buildURL()}/${apiPath}${path}/${id}`; - }, - - createRecord(store, type, snapshot) { - return this._super(...arguments).then((response) => { - // if the server does not return an id and one has not been set on the model we need to set it manually from the mutableId value - if (!response?.id && !snapshot.record.id) { - snapshot.record.id = snapshot.record.mutableId; - snapshot.id = snapshot.record.id; - } - return response; - }); - }, - }); - }, - - registerNewModelWithProps(helpUrl, backend, newModel, modelName) { - return this.getProps(helpUrl, backend).then((props) => { - const { attrs, newFields } = combineAttributes(newModel.attributes, props); - const owner = getOwner(this); - newModel = newModel.extend(attrs, { newFields }); - // if our newModel doesn't have fieldGroups already - // we need to create them - try { - // Initialize prototype to access field groups - let fieldGroups = newModel.proto().fieldGroups; - if (!fieldGroups) { - debug(`Constructing fieldGroups for ${backend}`); - fieldGroups = this.getFieldGroups(newModel); - newModel = newModel.extend({ fieldGroups }); - // Build and add validations on model - // NOTE: For initial phase, initialize validations only for user pass auth - if (backend === 'userpass') { - const validations = { - password: [ - { - validator(model) { - return ( - !(isPresent(model.password) && isPresent(model.passwordHash)) && - (isPresent(model.password) || isPresent(model.passwordHash)) - ); - }, - message: 'You must provide either password or password hash, but not both.', - }, - ], - }; - @withModelValidations(validations) - class GeneratedItemModel extends newModel {} - newModel = GeneratedItemModel; - } - } - } catch (err) { - // eat the error, fieldGroups is computed in the model definition - } - // attempting to set the id prop on a model will trigger an error - // this computed will be used in place of the the id fieldValue -- see openapi-to-attrs - newModel.reopen({ - mutableId: computed('id', '_id', { - get() { - return this._id || this.id; - }, - set(key, value) { - return (this._id = value); - }, - }), - }); - newModel.merged = true; - owner.unregister(modelName); - owner.register(modelName, newModel); - }); - }, - getFieldGroups(newModel) { - const groups = { - default: [], + paths = { + createPath: createPath.path, + deletePath: deletePath.path, + getPath: getPath.path, + }; }; - const fieldGroups = []; - newModel.attributes.forEach((attr) => { - // if the attr comes in with a fieldGroup from OpenAPI, - // add it to that group - if (attr.options.fieldGroup) { - if (groups[attr.options.fieldGroup]) { - groups[attr.options.fieldGroup].push(attr.name); - } else { - groups[attr.options.fieldGroup] = [attr.name]; - } - } else { - // otherwise just add that attr to the default group - groups.default.push(attr.name); - } - }); - for (const group in groups) { - fieldGroups.push({ [group]: groups[group] }); - } - return fieldToAttrs(newModel, fieldGroups); - }, -}); + } + + /** + * registerNewModelWithAttrs takes the helpUrl of the given model type, + * fetches props, and registers the model hydrated with the provided attrs + * @param {string} helpUrl like /v1/auth/userpass2/users/example?help=true + * @param {string} modelType like generated-user-userpass + */ + async registerNewModelWithAttrs(helpUrl, modelType) { + const owner = getOwner(this); + const props = await this.getProps(helpUrl); + const { attrs, newFields } = combineOpenApiAttrs(new Map(), props); + const NewKlass = this._upgradeModelSchema(GeneratedItemModel, attrs, newFields); + this._registerModel(owner, NewKlass, modelType, true); + } +} diff --git a/ui/app/utils/openapi-helpers.ts b/ui/app/utils/openapi-helpers.ts index eff0a36d05b2..20844fa4bd77 100644 --- a/ui/app/utils/openapi-helpers.ts +++ b/ui/app/utils/openapi-helpers.ts @@ -3,11 +3,10 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { dasherize } from '@ember/string'; +import { debug } from '@ember/debug'; +import { camelize, capitalize, dasherize } from '@ember/string'; import { singularize } from 'ember-inflector'; -// TODO: Consolidate with openapi-to-attrs once it's typescript - interface Path { path: string; itemType: string; @@ -166,3 +165,200 @@ export function getHelpUrlForModel(modelType: string, backend: string) { if (!urlFn) return null; return urlFn(backend); } + +interface Attribute { + name: string; + type: string | undefined; + options: { + editType?: string; + fieldGroup?: string; + fieldValue?: string; + label?: string; + readonly?: boolean; + }; +} + +interface OpenApiProp { + description: string; + type: string; + 'x-vault-displayAttrs': { + name: string; + value: string | number; + group: string; + sensitive: boolean; + editType?: string; + description?: string; + }; + items?: { type: string }; + format?: string; + isId?: boolean; + deprecated?: boolean; + enum?: string[]; +} +interface MixedAttr { + type?: string; + helpText?: string; + editType?: string; + fieldGroup: string; + fieldValue?: string; + label?: string; + readonly?: boolean; + possibleValues?: string[]; + defaultValue?: string | number | (() => string | number); + sensitive?: boolean; + readOnly?: boolean; + [key: string]: unknown; +} + +export const expandOpenApiProps = function (props: Record): Record { + const attrs: Record = {}; + // expand all attributes + for (const propName in props) { + const prop = props[propName]; + if (!prop) continue; + let { description, items, type, format, isId, deprecated } = prop; + if (deprecated === true) { + continue; + } + let { + name, + value, + group, + sensitive, + editType, + description: displayDescription, + } = prop['x-vault-displayAttrs'] || {}; + + if (type === 'integer') { + type = 'number'; + } + + if (displayDescription) { + description = displayDescription; + } + + editType = editType || type; + + if (format === 'seconds' || format === 'duration') { + editType = 'ttl'; + } else if (items) { + editType = items.type + capitalize(type); + } + + const attrDefn: MixedAttr = { + editType, + helpText: description, + possibleValues: prop['enum'], + fieldValue: isId ? 'mutableId' : undefined, + fieldGroup: group || 'default', + readOnly: isId, + defaultValue: value || undefined, + }; + + if (type === 'object' && !!value) { + attrDefn.defaultValue = () => { + return value; + }; + } + + if (sensitive) { + attrDefn.sensitive = true; + } + + // only set a label if we have one from OpenAPI + // otherwise the propName will be humanized by the form-field component + if (name) { + attrDefn.label = name; + } + + // ttls write as a string and read as a number + // so setting type on them runs the wrong transform + if (editType !== 'ttl' && type !== 'array') { + attrDefn.type = type; + } + + // loop to remove empty vals + for (const attrProp in attrDefn) { + if (attrDefn[attrProp] == null) { + delete attrDefn[attrProp]; + } + } + attrs[camelize(propName)] = attrDefn; + } + return attrs; +}; + +/** + * combineOpenApiAttrs takes attributes defined on an existing models + * and adds in the attributes found on an OpenAPI response. The values + * defined on the model should take precedence so we can overwrite + * attributes from OpenAPI. + */ +export const combineOpenApiAttrs = function ( + oldAttrs: Map, + openApiProps: Record +) { + const allAttrs: Record = {}; + const attrsArray: Attribute[] = []; + const newFields: string[] = []; + + // First iterate over all the existing attrs and combine with recieved props, if they exist + oldAttrs.forEach(function (oldAttr, name) { + const attr: Attribute = { name, type: oldAttr.type, options: oldAttr.options }; + const openApiProp = openApiProps[name]; + if (openApiProp) { + const { type, ...options } = openApiProp; + // TODO: previous behavior took the openApi type no matter what + attr.type = oldAttr.type ?? type; + if (oldAttr.type && type && type !== oldAttr.type) { + debug(`mismatched type for ${name} -- ${type} vs ${oldAttr.type}`); + } + attr.options = { ...options, ...oldAttr.options }; + } + attrsArray.push(attr); + // add to all attrs so we skip in the next part + allAttrs[name] = true; + }); + + // then iterate over all the new props and add them if they haven't already been accounted for + for (const name in openApiProps) { + // iterate over each + if (allAttrs[name]) { + continue; + } else { + const prop = openApiProps[name]; + if (prop) { + const { type, ...options } = prop; + newFields.push(name); + attrsArray.push({ name, type, options }); + } + } + } + return { attrs: attrsArray, newFields }; +}; + +// interface FieldGroups { +// default: string[]; +// [key: string]: string[]; +// } + +// export const combineFieldGroups = function ( +// currentGroups: Array>, +// newFields: string[], +// excludedFields: string[] +// ) { +// console.log({ currentGroups, newFields, excludedFields }); +// let allFields: string[] = []; +// for (const group of currentGroups) { +// const fields = Object.values(group)[0] || []; +// allFields = allFields.concat(fields); +// } +// const otherFields = newFields.filter((field) => { +// return !allFields.includes(field) && !excludedFields.includes(field); +// }); +// if (otherFields.length) { +// currentGroups[0].default = currentGroups[0].default.concat(otherFields); +// } + +// return currentGroups; +// }; diff --git a/ui/app/utils/openapi-to-attrs.js b/ui/app/utils/openapi-to-attrs.js index 31406b47e117..899a8946d18e 100644 --- a/ui/app/utils/openapi-to-attrs.js +++ b/ui/app/utils/openapi-to-attrs.js @@ -3,119 +3,14 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { attr } from '@ember-data/model'; -import { camelize, capitalize } from '@ember/string'; - -export const expandOpenApiProps = function (props) { - const attrs = {}; - // expand all attributes - for (const propName in props) { - const prop = props[propName]; - let { description, items, type, format, isId, deprecated } = prop; - if (deprecated === true) { - continue; - } - let { - name, - value, - group, - sensitive, - editType, - description: displayDescription, - } = prop['x-vault-displayAttrs'] || {}; - - if (type === 'integer') { - type = 'number'; - } - - if (displayDescription) { - description = displayDescription; - } - - editType = editType || type; - - if (format === 'seconds' || format === 'duration') { - editType = 'ttl'; - } else if (items) { - editType = items.type + capitalize(type); - } - - const attrDefn = { - editType, - helpText: description, - possibleValues: prop['enum'], - fieldValue: isId ? 'mutableId' : null, - fieldGroup: group || 'default', - readOnly: isId, - defaultValue: value || null, - }; - - if (type === 'object' && !!value) { - attrDefn.defaultValue = () => { - return value; - }; - } - - if (sensitive) { - attrDefn.sensitive = true; - } - - // only set a label if we have one from OpenAPI - // otherwise the propName will be humanized by the form-field component - if (name) { - attrDefn.label = name; - } - - // ttls write as a string and read as a number - // so setting type on them runs the wrong transform - if (editType !== 'ttl' && type !== 'array') { - attrDefn.type = type; - } - - // loop to remove empty vals - for (const attrProp in attrDefn) { - if (attrDefn[attrProp] == null) { - delete attrDefn[attrProp]; - } - } - attrs[camelize(propName)] = attrDefn; - } - return attrs; -}; - -export const combineAttributes = function (oldAttrs, newProps) { - const newAttrs = {}; - const newFields = []; - if (oldAttrs) { - oldAttrs.forEach(function (value, name) { - if (newProps[name]) { - newAttrs[name] = attr(newProps[name].type, { ...newProps[name], ...value.options }); - } else { - newAttrs[name] = attr(value.type, value.options); - } - }); - } - for (const prop in newProps) { - if (newAttrs[prop]) { - continue; - } else { - newAttrs[prop] = attr(newProps[prop].type, newProps[prop]); - newFields.push(prop); - } - } - return { attrs: newAttrs, newFields }; -}; - -export const combineFields = function (currentFields, newFields, excludedFields) { - const otherFields = newFields.filter((field) => { - return !currentFields.includes(field) && !excludedFields.includes(field); - }); - if (otherFields.length) { - currentFields = currentFields.concat(otherFields); - } - return currentFields; -}; - +/** + * combineFieldGroups takes the newFields returned from OpenAPI and adds them to the default field group + * if they are not already accounted for in other field groups + * @param {Record[]} currentGroups Field groups, as an array of objects like: [{ default: [] }, { 'TLS options': [] }] + * @param {string[]} newFields + * @param {string[]} excludedFields + * @returns modified currentGroups + */ export const combineFieldGroups = function (currentGroups, newFields, excludedFields) { let allFields = []; for (const group of currentGroups) { diff --git a/ui/tests/acceptance/auth-list-test.js b/ui/tests/acceptance/auth-list-test.js index 2f11fffc488d..1b762206a89f 100644 --- a/ui/tests/acceptance/auth-list-test.js +++ b/ui/tests/acceptance/auth-list-test.js @@ -8,11 +8,12 @@ import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { v4 as uuidv4 } from 'uuid'; -import authPage from 'vault/tests/pages/auth'; +import { login, loginNs } from 'vault/tests/helpers/auth/auth-helpers'; import enablePage from 'vault/tests/pages/settings/auth/enable'; import { supportedManagedAuthBackends } from 'vault/helpers/supported-managed-auth-backends'; import { deleteAuthCmd, mountAuthCmd, runCmd, createNS } from 'vault/tests/helpers/commands'; import { methods } from 'vault/helpers/mountable-auth-methods'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; const SELECTORS = { backendLink: (path) => `[data-test-auth-backend-link="${path}"]`, @@ -27,7 +28,7 @@ module('Acceptance | auth backend list', function (hooks) { setupApplicationTest(hooks); hooks.beforeEach(async function () { - await authPage.login(); + await login(); this.path1 = `userpass-${uuidv4()}`; this.path2 = `userpass-${uuidv4()}`; this.user1 = 'user1'; @@ -37,16 +38,16 @@ module('Acceptance | auth backend list', function (hooks) { }); hooks.afterEach(async function () { - await authPage.login(); + await login(); await runCmd([deleteAuthCmd(this.path1), deleteAuthCmd(this.path2)], false); return; }); test('userpass secret backend', async function (assert) { - assert.expect(5); // enable a user in first userpass backend await visit('/vault/access'); await click(SELECTORS.backendLink(this.path1)); + assert.dom(GENERAL.emptyStateTitle).exists('shows empty state'); await click(SELECTORS.createUser); await fillIn(SELECTORS.input('username'), this.user1); await fillIn(SELECTORS.password, this.user1); @@ -58,12 +59,12 @@ module('Acceptance | auth backend list', function (hooks) { // enable a user in second userpass backend await click(SELECTORS.backendLink(this.path2)); + assert.dom(GENERAL.emptyStateTitle).exists('shows empty state'); await click(SELECTORS.createUser); await fillIn(SELECTORS.input('username'), this.user2); await fillIn(SELECTORS.password, this.user2); await click(SELECTORS.saveBtn); assert.strictEqual(currentURL(), `/vault/access/${this.path2}/item/user`); - // Confirm that the user was created. There was a bug where the apiPath was not being updated when toggling between auth routes. assert.dom(SELECTORS.listItem).hasText(this.user2, 'user2 exists in the list'); @@ -133,7 +134,7 @@ module('Acceptance | auth backend list', function (hooks) { // Only SAML is enterprise-only for now const type = 'saml'; const path = `auth-list-${type}-${uid}`; - await enablePage.enable(type, path); + await runCmd(mountAuthCmd(type, path)); await settled(); await visit('/vault/access'); @@ -151,7 +152,7 @@ module('Acceptance | auth backend list', function (hooks) { const ns = 'ns-wxyz'; await runCmd(createNS(ns), false); await settled(); - await authPage.loginNs(ns); + await loginNs(ns); // go directly to token configure route await visit('/vault/settings/auth/configure/token/options'); await fillIn('[data-test-input="description"]', 'My custom description'); diff --git a/ui/tests/helpers/auth/auth-form-selectors.ts b/ui/tests/helpers/auth/auth-form-selectors.ts index eb8c77ce9dd1..b5a3730ea922 100644 --- a/ui/tests/helpers/auth/auth-form-selectors.ts +++ b/ui/tests/helpers/auth/auth-form-selectors.ts @@ -12,4 +12,5 @@ export const AUTH_FORM = { input: (item: string) => `[data-test-${item}]`, // i.e. jwt, role, token, password or username mountPathInput: '[data-test-auth-form-mount-path]', moreOptions: '[data-test-auth-form-options-toggle]', + namespaceInput: '[data-test-auth-form-ns-input]', }; diff --git a/ui/tests/helpers/auth/auth-helpers.ts b/ui/tests/helpers/auth/auth-helpers.ts index 65632c066861..544bdcb2a063 100644 --- a/ui/tests/helpers/auth/auth-helpers.ts +++ b/ui/tests/helpers/auth/auth-helpers.ts @@ -14,7 +14,16 @@ export const login = async (token = rootToken) => { await logout(); await visit('/vault/auth?with=token'); await fillIn(AUTH_FORM.input('token'), token); - return await click(AUTH_FORM.login); + return click(AUTH_FORM.login); +}; + +export const loginNs = async (ns: string, token = rootToken) => { + // make sure we're always logged out and logged back in + await logout(); + await visit('/vault/auth?with=token'); + await fillIn(AUTH_FORM.namespaceInput, ns); + await fillIn(AUTH_FORM.input('token'), token); + return click(AUTH_FORM.login); }; export const logout = async () => { diff --git a/ui/tests/unit/models/generated-item-test.js b/ui/tests/unit/models/generated-item-test.js new file mode 100644 index 000000000000..152ea64c3f81 --- /dev/null +++ b/ui/tests/unit/models/generated-item-test.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; + +import { setupTest } from 'vault/tests/helpers'; + +module('Unit | Model | generated item', function (hooks) { + setupTest(hooks); + + test('it exists', function (assert) { + const store = this.owner.lookup('service:store'); + const model = store.createRecord('generated-item', {}); + assert.ok(model); + }); +}); diff --git a/ui/tests/unit/utils/openapi-helpers-test.js b/ui/tests/unit/utils/openapi-helpers-test.js index a0f18a37bcdc..dd8dbfb648e3 100644 --- a/ui/tests/unit/utils/openapi-helpers-test.js +++ b/ui/tests/unit/utils/openapi-helpers-test.js @@ -4,9 +4,20 @@ */ import { module, test } from 'qunit'; -import { _getPathParam, getHelpUrlForModel, pathToHelpUrlSegment } from 'vault/utils/openapi-helpers'; +import { + _getPathParam, + combineOpenApiAttrs, + expandOpenApiProps, + getHelpUrlForModel, + pathToHelpUrlSegment, +} from 'vault/utils/openapi-helpers'; +import Model, { attr } from '@ember-data/model'; +import { setupTest } from 'ember-qunit'; +import { camelize } from '@ember/string'; + +module('Unit | Utility | OpenAPI helper utils', function (hooks) { + setupTest(hooks); -module('Unit | Utility | OpenAPI helper utils', function () { test(`pathToHelpUrlSegment`, function (assert) { [ { path: '/auth/{username}', result: '/auth/example' }, @@ -48,4 +59,236 @@ module('Unit | Utility | OpenAPI helper utils', function () { ); }); }); + + test('combineOpenApiAttrs should combine attributes correctly', async function (assert) { + class FooModel extends Model { + @attr('string', { + label: 'Foo', + subText: 'A form field', + }) + foo; + @attr('boolean', { + label: 'Bar', + subText: 'Maybe a checkbox', + }) + bar; + @attr('number', { + label: 'Baz', + subText: 'A number field', + }) + baz; + } + this.owner.register('model:foo', FooModel); + const myModel = this.owner.lookup('service:store').modelFor('foo'); + const newProps = { + foo: { + editType: 'ttl', + }, + baz: { + type: 'number', + editType: 'slider', + label: 'Old label', + }, + foobar: { + type: 'string', + label: 'Foo-bar', + }, + }; + const expected = [ + { + name: 'foo', + type: 'string', + options: { + label: 'Foo', + subText: 'A form field', + editType: 'ttl', + }, + }, + { + name: 'bar', + type: 'boolean', + options: { + label: 'Bar', + subText: 'Maybe a checkbox', + }, + }, + { + name: 'baz', + type: 'number', + options: { + label: 'Baz', // uses the value we set on the model + editType: 'slider', + subText: 'A number field', + }, + }, + { + name: 'foobar', + type: 'string', + options: { + label: 'Foo-bar', + }, + }, + ]; + const { attrs, newFields } = combineOpenApiAttrs(myModel.attributes, newProps); + assert.deepEqual(newFields, ['foobar'], 'correct newFields added'); + + // When combineOpenApiAttrs + assert.strictEqual(attrs.length, 4, 'correct number of attributes returned'); + expected.forEach((exp) => { + const name = exp.name; + const attr = attrs.find((a) => a.name === name); + assert.deepEqual(attr, exp, `${name} combined properly`); + }); + }); + + module('expandopenApiProps', function () { + const OPENAPI_RESPONSE_PROPS = { + ttl: { + type: 'string', + format: 'seconds', + description: 'this is a TTL!', + 'x-vault-displayAttrs': { + name: 'TTL', + }, + }, + 'awesome-people': { + type: 'array', + items: { + type: 'string', + }, + 'x-vault-displayAttrs': { + value: 'Grace Hopper,Lady Ada', + }, + }, + 'favorite-ice-cream': { + type: 'string', + enum: ['vanilla', 'chocolate', 'strawberry'], + }, + 'default-value': { + default: 30, + 'x-vault-displayAttrs': { + value: 300, + }, + type: 'integer', + }, + default: { + 'x-vault-displayAttrs': { + value: 30, + }, + type: 'integer', + }, + 'super-secret': { + type: 'string', + 'x-vault-displayAttrs': { + sensitive: true, + }, + description: 'A really secret thing', + }, + }; + const EXPANDED_PROPS = { + ttl: { + helpText: 'this is a TTL!', + editType: 'ttl', + label: 'TTL', + fieldGroup: 'default', + }, + awesomePeople: { + editType: 'stringArray', + defaultValue: 'Grace Hopper,Lady Ada', + fieldGroup: 'default', + }, + favoriteIceCream: { + editType: 'string', + type: 'string', + possibleValues: ['vanilla', 'chocolate', 'strawberry'], + fieldGroup: 'default', + }, + defaultValue: { + editType: 'number', + type: 'number', + defaultValue: 300, + fieldGroup: 'default', + }, + default: { + editType: 'number', + type: 'number', + defaultValue: 30, + fieldGroup: 'default', + }, + superSecret: { + type: 'string', + editType: 'string', + sensitive: true, + helpText: 'A really secret thing', + fieldGroup: 'default', + }, + }; + const OPENAPI_DESCRIPTIONS = { + token_bound_cidrs: { + type: 'array', + description: + 'Comma separated string or JSON list of CIDR blocks. If set, specifies the blocks of IP addresses which are allowed to use the generated token.', + items: { + type: 'string', + }, + 'x-vault-displayAttrs': { + description: + 'List of CIDR blocks. If set, specifies the blocks of IP addresses which are allowed to use the generated token.', + name: "Generated Token's Bound CIDRs", + group: 'Tokens', + }, + }, + blah_blah: { + type: 'array', + description: 'Comma-separated list of policies', + items: { + type: 'string', + }, + 'x-vault-displayAttrs': { + name: "Generated Token's Policies", + group: 'Tokens', + }, + }, + only_display_description: { + type: 'array', + items: { + type: 'string', + }, + 'x-vault-displayAttrs': { + description: 'Hello there, you look nice today', + }, + }, + }; + + const STRING_ARRAY_DESCRIPTIONS = { + token_bound_cidrs: { + helpText: + 'List of CIDR blocks. If set, specifies the blocks of IP addresses which are allowed to use the generated token.', + }, + blah_blah: { + helpText: 'Comma-separated list of policies', + }, + only_display_description: { + helpText: 'Hello there, you look nice today', + }, + }; + test('it creates objects from OpenAPI schema props', function (assert) { + assert.expect(6); + const generatedProps = expandOpenApiProps(OPENAPI_RESPONSE_PROPS); + for (const propName in EXPANDED_PROPS) { + assert.deepEqual(EXPANDED_PROPS[propName], generatedProps[propName], `correctly expands ${propName}`); + } + }); + test('it uses the description from the display attrs block if it exists', async function (assert) { + assert.expect(3); + const generatedProps = expandOpenApiProps(OPENAPI_DESCRIPTIONS); + for (const propName in STRING_ARRAY_DESCRIPTIONS) { + assert.strictEqual( + generatedProps[camelize(propName)].helpText, + STRING_ARRAY_DESCRIPTIONS[propName].helpText, + `correctly updates helpText for ${propName}` + ); + } + }); + }); }); diff --git a/ui/tests/unit/utils/openapi-to-attrs-test.js b/ui/tests/unit/utils/openapi-to-attrs-test.js index 3d4cd57081e0..560bff9f48da 100644 --- a/ui/tests/unit/utils/openapi-to-attrs-test.js +++ b/ui/tests/unit/utils/openapi-to-attrs-test.js @@ -3,215 +3,12 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { attr } from '@ember-data/model'; -import { expandOpenApiProps, combineAttributes, combineFieldGroups } from 'vault/utils/openapi-to-attrs'; +import { combineFieldGroups } from 'vault/utils/openapi-to-attrs'; import { module, test } from 'qunit'; -import { camelize } from '@ember/string'; - -module('Unit | Util | OpenAPI Data Utilities', function () { - const OPENAPI_RESPONSE_PROPS = { - ttl: { - type: 'string', - format: 'seconds', - description: 'this is a TTL!', - 'x-vault-displayAttrs': { - name: 'TTL', - }, - }, - 'awesome-people': { - type: 'array', - items: { - type: 'string', - }, - 'x-vault-displayAttrs': { - value: 'Grace Hopper,Lady Ada', - }, - }, - 'favorite-ice-cream': { - type: 'string', - enum: ['vanilla', 'chocolate', 'strawberry'], - }, - 'default-value': { - default: 30, - 'x-vault-displayAttrs': { - value: 300, - }, - type: 'integer', - }, - default: { - 'x-vault-displayAttrs': { - value: 30, - }, - type: 'integer', - }, - 'super-secret': { - type: 'string', - 'x-vault-displayAttrs': { - sensitive: true, - }, - description: 'A really secret thing', - }, - }; - const EXPANDED_PROPS = { - ttl: { - helpText: 'this is a TTL!', - editType: 'ttl', - label: 'TTL', - fieldGroup: 'default', - }, - awesomePeople: { - editType: 'stringArray', - defaultValue: 'Grace Hopper,Lady Ada', - fieldGroup: 'default', - }, - favoriteIceCream: { - editType: 'string', - type: 'string', - possibleValues: ['vanilla', 'chocolate', 'strawberry'], - fieldGroup: 'default', - }, - defaultValue: { - editType: 'number', - type: 'number', - defaultValue: 300, - fieldGroup: 'default', - }, - default: { - editType: 'number', - type: 'number', - defaultValue: 30, - fieldGroup: 'default', - }, - superSecret: { - type: 'string', - editType: 'string', - sensitive: true, - helpText: 'A really secret thing', - fieldGroup: 'default', - }, - }; - - const EXISTING_MODEL_ATTRS = [ - { - key: 'name', - value: { - isAttribute: true, - name: 'name', - options: { - editType: 'string', - label: 'Role name', - }, - }, - }, - { - key: 'awesomePeople', - value: { - isAttribute: true, - name: 'awesomePeople', - options: { - label: 'People Who Are Awesome', - }, - }, - }, - ]; - - const COMBINED_ATTRS = { - name: attr('string', { - editType: 'string', - type: 'string', - label: 'Role name', - }), - ttl: attr('string', { - editType: 'ttl', - label: 'TTL', - helpText: 'this is a TTL!', - }), - awesomePeople: attr({ - label: 'People Who Are Awesome', - editType: 'stringArray', - defaultValue: 'Grace Hopper,Lady Ada', - }), - favoriteIceCream: attr('string', { - type: 'string', - editType: 'string', - possibleValues: ['vanilla', 'chocolate', 'strawberry'], - }), - superSecret: attr('string', { - type: 'string', - editType: 'string', - sensitive: true, - description: 'A really secret thing', - }), - }; +module('Unit | Util | combineFieldGroups', function () { const NEW_FIELDS = ['one', 'two', 'three']; - const OPENAPI_DESCRIPTIONS = { - token_bound_cidrs: { - type: 'array', - description: - 'Comma separated string or JSON list of CIDR blocks. If set, specifies the blocks of IP addresses which are allowed to use the generated token.', - items: { - type: 'string', - }, - 'x-vault-displayAttrs': { - description: - 'List of CIDR blocks. If set, specifies the blocks of IP addresses which are allowed to use the generated token.', - name: "Generated Token's Bound CIDRs", - group: 'Tokens', - }, - }, - blah_blah: { - type: 'array', - description: 'Comma-separated list of policies', - items: { - type: 'string', - }, - 'x-vault-displayAttrs': { - name: "Generated Token's Policies", - group: 'Tokens', - }, - }, - only_display_description: { - type: 'array', - items: { - type: 'string', - }, - 'x-vault-displayAttrs': { - description: 'Hello there, you look nice today', - }, - }, - }; - - const STRING_ARRAY_DESCRIPTIONS = { - token_bound_cidrs: { - helpText: - 'List of CIDR blocks. If set, specifies the blocks of IP addresses which are allowed to use the generated token.', - }, - blah_blah: { - helpText: 'Comma-separated list of policies', - }, - only_display_description: { - helpText: 'Hello there, you look nice today', - }, - }; - - test('it creates objects from OpenAPI schema props', function (assert) { - assert.expect(6); - const generatedProps = expandOpenApiProps(OPENAPI_RESPONSE_PROPS); - for (const propName in EXPANDED_PROPS) { - assert.deepEqual(EXPANDED_PROPS[propName], generatedProps[propName], `correctly expands ${propName}`); - } - }); - - test('it combines OpenAPI props with existing model attrs', function (assert) { - assert.expect(3); - const combined = combineAttributes(EXISTING_MODEL_ATTRS, EXPANDED_PROPS); - for (const propName in EXISTING_MODEL_ATTRS) { - assert.deepEqual(COMBINED_ATTRS[propName], combined[propName]); - } - }); - test('it adds new fields from OpenAPI to fieldGroups except for exclusions', function (assert) { assert.expect(3); const modelFieldGroups = [ @@ -280,16 +77,4 @@ module('Unit | Util | OpenAPI Data Utilities', function () { assert.deepEqual(fieldGroups[groupName], expectedGroups[groupName], 'it incorporates all new fields'); } }); - - test('it uses the description from the display attrs block if it exists', async function (assert) { - assert.expect(3); - const generatedProps = expandOpenApiProps(OPENAPI_DESCRIPTIONS); - for (const propName in STRING_ARRAY_DESCRIPTIONS) { - assert.strictEqual( - generatedProps[camelize(propName)].helpText, - STRING_ARRAY_DESCRIPTIONS[propName].helpText, - `correctly updates helpText for ${propName}` - ); - } - }); });