diff --git a/ui/app/adapters/totp-key.js b/ui/app/adapters/totp-key.js deleted file mode 100644 index c746b953b1..0000000000 --- a/ui/app/adapters/totp-key.js +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2025 - * SPDX-License-Identifier: BUSL-1.1 - */ - -import ApplicationAdapter from './application'; -import { encodePath } from 'vault/utils/path-encoding-helpers'; -import { isEmpty } from '@ember/utils'; - -export default class TotpKeyAdapter extends ApplicationAdapter { - namespace = 'v1'; - - // TOTP keys can only be created, so no need for an update method - createRecord(store, type, snapshot) { - const { name, backend } = snapshot.record; - const serializer = store.serializerFor(type.modelName); - const data = serializer.serialize(snapshot); - const url = this.urlForKey(backend, name); - - return this.ajax(url, 'POST', { data }).then((resp) => { - // Ember data doesn't like 204 responses except for DELETE method - const response = resp || { data: {} }; - response.data.id = name; - return response; - }); - } - - deleteRecord(store, type, snapshot) { - const { id } = snapshot; - return this.ajax(this.urlForKey(snapshot.record.backend, id), 'DELETE'); - } - - urlForKey(backend, id) { - let url = `${this.buildURL()}/${encodePath(backend)}/keys`; - - if (!isEmpty(id)) { - url = `${url}/${encodePath(id)}`; - } - - return url; - } - - query(store, type, query) { - const { backend } = query; - return this.ajax(this.urlForKey(backend), 'GET', { data: { list: true } }).then((resp) => { - resp.backend = backend; - return resp; - }); - } - - queryRecord(store, type, query) { - const { id, backend } = query; - return this.ajax(this.urlForKey(backend, id), 'GET').then((resp) => { - resp.id = id; - resp.backend = backend; - return resp; - }); - } - - generateCode(backend, id) { - return this.ajax(`${this.buildURL()}/${encodePath(backend)}/code/${id}`, 'GET').then((res) => { - return res.data; - }); - } -} diff --git a/ui/app/components/generate-credentials-totp.js b/ui/app/components/generate-credentials-totp.js index f8b22220fd..e1fc32bb29 100644 --- a/ui/app/components/generate-credentials-totp.js +++ b/ui/app/components/generate-credentials-totp.js @@ -14,7 +14,7 @@ const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; export default class GenerateCredentialsTotp extends Component { @tracked elapsedTime = 0; @tracked totpCode = null; - @service store; + @service api; @service router; title = 'Generate TOTP code'; @@ -61,8 +61,8 @@ export default class GenerateCredentialsTotp extends Component { async generateTotpCode(backend, keyName) { // refreshing will generate a new code if the period has expired. try { - const totpCode = await this.store.adapterFor('totp-key').generateCode(backend, keyName); - this.totpCode = totpCode.code; + const resp = await this.api.secrets.totpGenerateCode(keyName, backend); + this.totpCode = resp?.data?.code ?? null; } catch (e) { // swallow error, non-essential data return; diff --git a/ui/app/components/totp-edit.hbs b/ui/app/components/totp-edit.hbs index c01faec93f..b334097ee6 100644 --- a/ui/app/components/totp-edit.hbs +++ b/ui/app/components/totp-edit.hbs @@ -6,7 +6,7 @@ <:breadcrumbs> {{#if (eq @mode "show")}} - + {{else if (eq @mode "create")}} {{#if this.hasGenerated}} - + {{else}} diff --git a/ui/app/components/totp-edit.js b/ui/app/components/totp-edit.js index 223682ebb6..02b96c4287 100644 --- a/ui/app/components/totp-edit.js +++ b/ui/app/components/totp-edit.js @@ -9,17 +9,17 @@ import { tracked } from '@glimmer/tracking'; import { service } from '@ember/service'; import { task } from 'ember-concurrency'; import { waitFor } from '@ember/test-waiters'; -import errorMessage from 'vault/utils/error-message'; /** * @module TotpEdit * `TotpEdit` is a component that allows you to create, view or delete a TOTP key. * When creating a key if `generate` and `exported` are true then after a successful save the UI renders a QR code for the generated key. * @example - * + * * - * @param {object} model - The totp key ember data model. + * @param {object} form - The TotpKeyForm instance. * @param {string} mode - The mode to render. Either 'create' or 'show'. + * @param {object} capabilities - Capabilities object with canDelete, canRead flags. */ const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root'; const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; @@ -27,12 +27,31 @@ const SHOW_ROUTE = 'vault.cluster.secrets.backend.show'; export default class TotpEdit extends Component { @service router; @service flashMessages; + @service api; @tracked hasGenerated = false; @tracked invalidFormAlert = ''; @tracked modelValidations; + @tracked key; - successCallback; + constructor(owner, args) { + super(owner, args); + // In show mode, the route fetches the key and passes it via @form + // This stores it in our tracked property for consistent data source + if (args.mode === 'show') { + this.key = args.form.data; + } + } + + displayFields = [ + { field: 'account_name', label: 'Account name' }, + { field: 'algorithm', label: 'Algorithm' }, + { field: 'digits', label: 'Digits' }, + { field: 'issuer', label: 'Issuer' }, + { field: 'period', label: 'Period' }, + ]; + + generatedFields = [{ field: 'url', label: 'URL' }]; breadcrumbs = [ { label: 'Vault', text: 'Vault', icon: 'vault', path: 'vault.cluster.dashboard' }, @@ -50,29 +69,7 @@ export default class TotpEdit extends Component { get subtitle() { if (this.args.mode === 'create') return ''; - - return this.args.model.id; - } - - get defaultKeyFormFields() { - const shared = ['name', 'generate', 'issuer', 'accountName']; - const generated = [...shared, 'exported']; - const nonGenerated = [...shared, 'url', 'key']; - return this.args.model.generate ? generated : nonGenerated; - } - - get groups() { - const { generate } = this.args.model; - - const groups = { - 'TOTP Code Options': ['algorithm', 'digits', 'period'], - }; - - if (generate) { - groups['Provider Options'] = ['keySize', 'skew', 'qrSize']; - } - - return groups; + return this.args.form.data.name; } transitionToRoute() { @@ -81,52 +78,49 @@ export default class TotpEdit extends Component { @action reset() { - const { name } = this.args.model; - this.args.model.unloadRecord(); + const { name } = this.args.form.data; this.transitionToRoute(SHOW_ROUTE, name); } @action async deleteKey() { try { - const { id } = this.args.model; - await this.args.model.destroyRecord(); + const { name, backend } = this.args.form.data; + await this.api.secrets.totpDeleteKey(name, backend); this.transitionToRoute(LIST_ROOT_ROUTE); - this.flashMessages.success(`${id} was successfully deleted.`); + this.flashMessages.success(`${name} was successfully deleted.`); } catch (err) { - this.flashMessages.danger(errorMessage(err)); + const { message } = await this.api.parseError(err); + this.flashMessages.danger(message); } } createKey = task( waitFor(async (event) => { event.preventDefault(); - const { isValid, state, invalidFormMessage } = this.args.model.validate(); + const { isValid, state, invalidFormMessage, data } = this.args.form.toJSON(); this.modelValidations = isValid ? null : state; this.invalidFormAlert = invalidFormMessage; if (!isValid) return; try { - const allFields = [...this.defaultKeyFormFields, ...Object.values(this.groups).flat()]; - await this.args.model.save({ - adapterOptions: { - keyFormFields: allFields, - }, - }); - const { generate, exported } = this.args.model; + const { name, backend, generate, exported } = this.args.form.data; + const resp = await this.api.secrets.totpCreateKey(name, backend, data); if (generate && exported) { // stay in this template and show QR code returned from response + if (resp?.data) { + this.key = resp.data; + } this.hasGenerated = true; } else { - // nothing is returned from response, transition to key details route - this.transitionToRoute(SHOW_ROUTE, this.args.model.name); + this.transitionToRoute(SHOW_ROUTE, name); } + this.flashMessages.success('Successfully created key.'); } catch (err) { - // err will display via model state - return; + const { message } = await this.api.parseError(err); + this.flashMessages.danger(message); } - this.flashMessages.success('Successfully created key.'); }) ); } diff --git a/ui/app/components/totp/key-create-toggle-groups.hbs b/ui/app/components/totp/key-create-toggle-groups.hbs deleted file mode 100644 index 75b7df8db7..0000000000 --- a/ui/app/components/totp/key-create-toggle-groups.hbs +++ /dev/null @@ -1,31 +0,0 @@ -{{! - Copyright IBM Corp. 2016, 2025 - SPDX-License-Identifier: BUSL-1.1 -}} - -{{#each-in @groups as |group fields|}} - - {{#if (eq this.showGroup group)}} -
- {{#each fields as |fieldName|}} - {{#let (find-by "name" fieldName @model.allFields) as |attr|}} - - {{/let}} - {{/each}} - -
- {{/if}} -{{/each-in}} \ No newline at end of file diff --git a/ui/app/components/totp/key-create-toggle-groups.js b/ui/app/components/totp/key-create-toggle-groups.js deleted file mode 100644 index 709556e086..0000000000 --- a/ui/app/components/totp/key-create-toggle-groups.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2025 - * SPDX-License-Identifier: BUSL-1.1 - */ - -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { action } from '@ember/object'; - -export default class KeyCreateToggleGroupsComponent extends Component { - @tracked showGroup = null; - - @action - toggleGroup(group, isOpen) { - this.showGroup = isOpen ? group : null; - } -} diff --git a/ui/app/components/totp/key-create.hbs b/ui/app/components/totp/key-create.hbs index f9347cdb4e..81239833ed 100644 --- a/ui/app/components/totp/key-create.hbs +++ b/ui/app/components/totp/key-create.hbs @@ -5,15 +5,8 @@
- - {{#each @defaultKeyFormFields as |field|}} - {{#let (find-by "name" field @model.allFields) as |attr|}} - - {{/let}} - {{/each}} - - +
@@ -21,7 +14,7 @@ @text="Cancel" @color="secondary" @route="vault.cluster.secrets.backend.list-root" - @model={{@model.backend}} + @model={{@form.data.backend}} @query={{hash tab="key"}} /> diff --git a/ui/app/components/totp/key-details.hbs b/ui/app/components/totp/key-details.hbs index ee93348a1d..78a900694e 100644 --- a/ui/app/components/totp/key-details.hbs +++ b/ui/app/components/totp/key-details.hbs @@ -5,10 +5,10 @@ - + Generate code - {{#if @model.canDelete}} + {{#if @capabilities.canDelete}}
- {{#each @model.attrs as |attr|}} - + {{#each @displayFields as |item|}} + {{/each}}
\ No newline at end of file diff --git a/ui/app/components/totp/key-qr-code.hbs b/ui/app/components/totp/key-qr-code.hbs index 8ddff4db30..dc932f1675 100644 --- a/ui/app/components/totp/key-qr-code.hbs +++ b/ui/app/components/totp/key-qr-code.hbs @@ -4,36 +4,29 @@ }}
- - {{#unless @model.isError}} - - Warning - - You will not be able to access this information later, so please copy the information below. - - - {{/unless}} - {{#each @model.generatedAttrs as |attr|}} - + + Warning + + You will not be able to access this information later, so please copy the information below. + + + {{#each @generatedFields as |item|}} + {{/each}} - {{#if (gt @model.qrSize 0)}} + {{#if (gt @qrSize 0)}}
{{/if}} -
diff --git a/ui/app/controllers/vault/cluster/secrets/backend/list.js b/ui/app/controllers/vault/cluster/secrets/backend/list.js index d07bd1d945..ec880dc41a 100644 --- a/ui/app/controllers/vault/cluster/secrets/backend/list.js +++ b/ui/app/controllers/vault/cluster/secrets/backend/list.js @@ -74,6 +74,15 @@ export default Controller.extend(ListController, BackendCrumbMixin, { const { message } = await this.api.parseError(e); this.flashMessages.danger(message); } + } else if (this.backendType === 'totp') { + try { + await this.api.secrets.totpDeleteKey(name, item.backend); + this.flashMessages.success(`${name} was successfully deleted.`); + this.send('reload'); + } catch (e) { + const { message } = await this.api.parseError(e); + this.flashMessages.danger(message); + } } else { // Handle Ember Data models item diff --git a/ui/app/forms/totp/key.ts b/ui/app/forms/totp/key.ts new file mode 100644 index 0000000000..c27542948a --- /dev/null +++ b/ui/app/forms/totp/key.ts @@ -0,0 +1,173 @@ +/** + * Copyright IBM Corp. 2016, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Form from 'vault/forms/form'; +import FormField from 'vault/utils/forms/field'; +import FormFieldGroup from 'vault/utils/forms/field-group'; +import type { Validations } from 'vault/app-types'; +import type { TotpCreateKeyRequest } from '@hashicorp/vault-client-typescript'; + +type TotpKeyData = TotpCreateKeyRequest & { + name?: string; + backend?: string; + barcode?: string; +}; + +export default class TotpKeyForm extends Form { + get generateString(): string { + return this.data.generate !== false ? 'Vault' : 'Other service'; + } + + set generateString(value: string) { + this.data.generate = value === 'Vault'; + } + + get formFieldGroups() { + const isVaultGenerated = this.data.generate !== false; + + const conditionalFields = isVaultGenerated + ? [ + new FormField('exported', 'boolean', { + editType: 'toggleButton', + defaultValue: true, + helperTextEnabled: 'QR code and URL will be returned upon generating a key.', + helperTextDisabled: 'Vault will not return QR code and url upon key creation.', + }), + ] + : [ + new FormField('url', 'string', { + label: 'URL', + helpText: + 'If a URL is provided the other fields can be left empty. E.g. otpauth://totp/Vault:test@test.com?secret=&issuer=Vault', + subText: 'The TOTP key url string that can be used to configure a key.', + }), + new FormField('key', 'string', { + subText: 'The root key used to generate a TOTP code.', + }), + ]; + + const defaultFields = [ + new FormField('name', 'string', { + subText: 'Specifies the name for this key.', + editDisabled: !this.isNew, + }), + new FormField('generate', 'boolean', { + label: 'Key Provider', + editType: 'radio', + possibleValues: ['Vault', 'Other service'], + defaultValue: true, + fieldValue: 'generateString', + subText: 'Specifies if the key should be generated by Vault or passed from another service.', + }), + new FormField('issuer', 'string', { + subText: "The name of the key's issuing organization. Required for keys generated by Vault.", + }), + new FormField('account_name', 'string', { + label: 'Account name', + subText: 'The name of the account associated with the key. Required for keys generated by Vault.', + }), + ...conditionalFields, + ]; + + const codeOptionsGroup = new FormFieldGroup('TOTP Code Options', [ + new FormField('algorithm', 'string', { + possibleValues: ['SHA1', 'SHA256', 'SHA512'], + defaultValue: 'SHA1', + }), + new FormField('digits', 'number', { + possibleValues: [6, 8], + defaultValue: 6, + }), + new FormField('period', 'string', { + editType: 'ttl', + helperTextEnabled: 'How long each generated TOTP is valid.', + defaultValue: 30, + }), + ]); + + const groups: FormFieldGroup[] = [new FormFieldGroup('default', defaultFields), codeOptionsGroup]; + + if (isVaultGenerated) { + groups.push( + new FormFieldGroup('Provider Options', [ + new FormField('key_size', 'number', { + label: 'Key size', + defaultValue: 20, + }), + new FormField('skew', 'number', { + possibleValues: [0, 1], + defaultValue: 1, + }), + new FormField('qr_size', 'number', { + label: 'QR size', + defaultValue: 200, + }), + ]) + ); + } + + return groups; + } + + validations: Validations = { + account_name: [ + { + validator: (data: TotpKeyData) => { + return data.generate === false || !!data.account_name; + }, + message: "Account name can't be blank when the key is generated by Vault.", + }, + { + type: 'containsWhiteSpace', + message: + "Account name contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.", + level: 'warn', + }, + ], + issuer: [ + { + validator: (data: TotpKeyData) => data.generate === false || !!data.issuer, + message: "Issuer can't be blank when the key is generated by Vault.", + }, + ], + key: [ + { + validator: (data: TotpKeyData) => data.generate !== false || !!data.url || !!data.key, + message: "Key can't be blank if key is being passed from another service and the URL is empty.", + }, + ], + key_size: [{ type: 'number', message: 'Key size must be a number.' }], + name: [ + { type: 'presence', message: "Name can't be blank." }, + { + type: 'containsWhiteSpace', + message: + "Name contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.", + level: 'warn', + }, + ], + qr_size: [{ type: 'number', message: 'QR size must be a number.' }], + }; + + toJSON() { + const { isValid, state, invalidFormMessage } = super.toJSON(); + const data = { ...this.data } as Record; + delete data['name']; + delete data['backend']; + delete data['barcode']; + + if (data['generate'] !== false) { + delete data['url']; + delete data['key']; + } else { + delete data['key_size']; + delete data['skew']; + delete data['exported']; + delete data['qr_size']; + } + + return { isValid, state, invalidFormMessage, data }; + } +} diff --git a/ui/app/models/totp-key.js b/ui/app/models/totp-key.js deleted file mode 100644 index d7d95c2ac1..0000000000 --- a/ui/app/models/totp-key.js +++ /dev/null @@ -1,191 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2025 - * SPDX-License-Identifier: BUSL-1.1 - */ - -import Model, { attr } from '@ember-data/model'; -import { withFormFields } from 'vault/decorators/model-form-fields'; -import { withModelValidations } from 'vault/decorators/model-validations'; -import { withExpandedAttributes } from 'vault/decorators/model-expanded-attributes'; -import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; -import { isPresent } from '@ember/utils'; - -const validations = { - accountName: [ - { - validator(model) { - const { generate, accountName } = model; - // this is required when generate is true - return generate && !isPresent(accountName) ? false : true; - }, - message: "Account name can't be blank when the key is generated by Vault.", - }, - { - type: 'containsWhiteSpace', - message: - "Account name contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.", - level: 'warn', - }, - ], - issuer: [ - { - validator(model) { - const { generate, issuer } = model; - // this is required when generate is true - return generate && !isPresent(issuer) ? false : true; - }, - message: "Issuer can't be blank when when the key is generated by Vault.", - }, - ], - key: [ - { - validator(model) { - const { generate, key, url } = model; - // this is required when generate is false and url is blank - return !generate && !isPresent(url) && !isPresent(key) ? false : true; - }, - message: "Key can't be blank if key is being passed from another service and the URL is empty.", - }, - ], - keySize: [{ type: 'number', message: 'Key size must be a number.' }], - name: [ - { type: 'presence', message: "Name can't be blank." }, - { - type: 'containsWhiteSpace', - message: - "Name contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.", - level: 'warn', - }, - ], - qrSize: [{ type: 'number', message: 'QR size must be a number' }], -}; - -@withModelValidations(validations) -@withExpandedAttributes() -@withFormFields() -export default class TotpKeyModel extends Model { - @attr('string', { - readOnly: true, - }) - backend; - - @attr('string', { - subText: 'Specifies the name for this key.', - }) - name; - - @attr('string', { - subText: 'The name of the account associated with the key. Required for keys generated by Vault.', - }) - accountName; - - @attr('string', { - possibleValues: ['SHA1', 'SHA256', 'SHA512'], - defaultValue: 'SHA1', - }) - algorithm; - - @attr('number', { - possibleValues: [6, 8], - defaultValue: 6, - }) - digits; - - @attr('string', { - subText: `The name of the key's issuing organization. Required for keys generated by Vault.`, - }) - issuer; - - @attr({ - editType: 'ttl', - helperTextEnabled: 'How long each generated TOTP is valid.', - defaultValue: 30, // API accepts both an integer as seconds and string with unit e.g 30 || '30s' - }) - period; - - // The generate attr is a boolean. The generateString getter and setter is used only in forms to get and set the boolean via - // strings values. The payload params expect the attr to be a boolean value. - @attr({ - label: 'Key Provider', - defaultValue: true, - editType: 'radio', - possibleValues: ['Vault', 'Other service'], - fieldValue: 'generateString', - subText: 'Specifies if the key should be generated by Vault or passed from another service.', - }) - generate; - - // Used when generate is true - @attr('number', { - defaultValue: 20, - }) - keySize; - - @attr('number', { - possibleValues: [0, 1], - defaultValue: 1, - }) - skew; - - @attr('boolean', { - editType: 'toggleButton', - defaultValue: true, - helperTextDisabled: 'Vault will not return QR code and url upon key creation.', - helperTextEnabled: 'QR code and URL will be returned upon generating a key.', - }) - exported; - - @attr('number', { - label: 'QR size', - defaultValue: 200, - }) - qrSize; - - // Used when generate is false - @attr('string', { - label: 'URL', - helpText: - 'If a URL is provided the other fields can be left empty. E.g. otpauth://totp/Vault:test@test.com?secret=&issuer=Vault', - subText: 'The TOTP key url string that can be used to configure a key.', - }) - url; - - @attr('string', { - subText: 'The root key used to generate a TOTP code.', - }) - key; - - // Returned when a key is created as provider - @attr('string', { - readOnly: true, - }) - barcode; - - get attrs() { - const keys = ['accountName', 'name', 'algorithm', 'digits', 'issuer', 'period']; - return keys.map((k) => this.allByKey[k]); - } - - get generatedAttrs() { - const keys = ['url']; - return keys.map((k) => this.allByKey[k]); - } - - get generateString() { - return this.generate ? 'Vault' : 'Other service'; - } - - set generateString(value) { - this.generate = value === 'Vault' ? true : false; - } - - @lazyCapabilities(apiPath`${'backend'}/keys/${'id'}`, 'backend', 'id') keyPath; - - get canDelete() { - return this.keyPath.get('canDelete'); - } - - get canRead() { - return this.keyPath.get('canRead'); - } -} diff --git a/ui/app/routes/vault/cluster/secrets/backend/create-root.js b/ui/app/routes/vault/cluster/secrets/backend/create-root.js index b8d89db969..87d2a2e2cf 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/create-root.js +++ b/ui/app/routes/vault/cluster/secrets/backend/create-root.js @@ -8,6 +8,7 @@ import { service } from '@ember/service'; import EditBase from './secret-edit'; import KeymgmtKeyForm from 'vault/forms/keymgmt/key'; import KeymgmtProviderForm from 'vault/forms/keymgmt/provider'; +import TotpKeyForm from 'vault/forms/totp/key'; import { KeyManagementUpdateKeyRequestTypeEnum } from '@hashicorp/vault-client-typescript'; const secretModel = (store, backend, key) => { @@ -49,6 +50,23 @@ export default EditBase.extend({ return new KeymgmtProviderForm(defaultValues, { isNew: true }); } + if (modelType === 'totp-key') { + return new TotpKeyForm( + { + backend, + generate: true, + algorithm: 'SHA1', + digits: 6, + period: 30, + exported: true, + key_size: 20, + skew: 1, + qr_size: 200, + }, + { isNew: true } + ); + } + if (modelType === 'role-ssh') { return this.store.createRecord(modelType, { keyType: 'ca' }); } diff --git a/ui/app/routes/vault/cluster/secrets/backend/credentials.js b/ui/app/routes/vault/cluster/secrets/backend/credentials.js index 13513940ec..69a8dbead4 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/credentials.js +++ b/ui/app/routes/vault/cluster/secrets/backend/credentials.js @@ -14,6 +14,7 @@ export default Route.extend({ pathHelp: service('path-help'), router: service(), store: service(), + api: service(), beforeModel(transition) { const { id: backendPath, type: backendType } = this.modelFor('vault.cluster.secrets.backend'); @@ -56,11 +57,11 @@ export default Route.extend({ async getTotpKey(backend, keyName) { try { - const key = await this.store.queryRecord('totp-key', { id: keyName, backend }); - return key; + const resp = await this.api.secrets.totpReadKey(keyName, backend); + return resp.data || {}; } catch (e) { // swallow error, non-essential data - return; + return {}; } }, diff --git a/ui/app/routes/vault/cluster/secrets/backend/list.js b/ui/app/routes/vault/cluster/secrets/backend/list.js index 1ad8985f06..327c1cc12d 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/list.js +++ b/ui/app/routes/vault/cluster/secrets/backend/list.js @@ -21,6 +21,7 @@ import { resolve } from 'rsvp'; import { SecretsApiKeyManagementListKeysListEnum, SecretsApiKeyManagementListKmsProvidersListEnum, + SecretsApiTotpListKeysListEnum, } from '@hashicorp/vault-client-typescript'; const SUPPORTED_BACKENDS = supportedSecretBackends(); @@ -97,8 +98,8 @@ export default Route.extend({ return this.router.transitionTo('vault.cluster.secrets.backend.kv.list', backend); } const modelType = this.getModelType(effectiveType, tab); - // Keymgmt routes use API-backed forms instead of Ember Data models, so skip model hydration. - if (effectiveType === 'keymgmt') { + // Keymgmt and TOTP routes use API-backed forms instead of Ember Data models, so skip model hydration. + if (effectiveType === 'keymgmt' || effectiveType === 'totp') { return resolve(); } @@ -111,6 +112,35 @@ export default Route.extend({ return getModelTypeForEngine(type, { tab }); }, + async fetchTotpKeys(backend, page, pageFilter) { + try { + const resp = await this.api.secrets.totpListKeys(backend, SecretsApiTotpListKeysListEnum.TRUE); + const keys = resp.keys || []; + + const pathsToFetch = keys.map((name) => this.capabilitiesService.pathFor('totpKey', { backend, name })); + const capabilities = pathsToFetch.length ? await this.capabilitiesService.fetch(pathsToFetch) : {}; + + const items = keys.map((name) => { + const keyPath = this.capabilitiesService.pathFor('totpKey', { backend, name }); + return { + id: name, + name, + backend, + canRead: capabilities[keyPath]?.canRead || false, + canDelete: capabilities[keyPath]?.canDelete || false, + }; + }); + + return paginate(items, { page, filter: pageFilter }); + } catch (error) { + const { status } = await this.api.parseError(error); + if (status === 404) { + return []; + } + throw error; + } + }, + async fetchKeysWithCapabilities(backend) { const { keys } = await this.api.secrets.keyManagementListKeys( backend, @@ -217,9 +247,13 @@ export default Route.extend({ const effectiveType = getEffectiveEngineType(backendModel.engineType); const modelType = this.getModelType(effectiveType, params.tab); - // Handle keymgmt keys with API service + // Handle keymgmt and TOTP resources with API service let secrets; - if (effectiveType === 'keymgmt') { + if (effectiveType === 'totp') { + const page = getValidPage(params.page); + secrets = await this.fetchTotpKeys(backend, page, params.pageFilter); + this.set('has404', false); + } else if (effectiveType === 'keymgmt') { const page = getValidPage(params.page); const filter = params.pageFilter; secrets = diff --git a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js index 17a0e96c3a..b4a3f79e1c 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js +++ b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js @@ -16,6 +16,7 @@ import { getBackendEffectiveType, getEnginePathParam } from 'vault/utils/backend import { isValidProvider } from 'vault/utils/keymgmt-provider-utils'; import KeymgmtKeyForm from 'vault/forms/keymgmt/key'; import KeymgmtProviderForm from 'vault/forms/keymgmt/provider'; +import TotpKeyForm from 'vault/forms/totp/key'; import { SecretsApiKeyManagementListKmsProvidersForKeyListEnum, SecretsApiTransformListRolesListEnum, @@ -126,8 +127,8 @@ export default Route.extend({ buildModel(secret, queryParams) { const backend = getEnginePathParam(this); const modelType = this.modelType(backend, secret, { queryParams }); - // Keymgmt resources are loaded through API-backed forms, so Ember Data hydration is unnecessary. - if (modelType === 'secret' || modelType.startsWith('keymgmt/')) { + // Keymgmt and TOTP resources are loaded through API-backed forms, so Ember Data hydration is unnecessary. + if (modelType === 'secret' || modelType.startsWith('keymgmt/') || modelType === 'totp-key') { return resolve(); } return this.pathHelp.hydrateModel(modelType, backend); @@ -221,6 +222,32 @@ export default Route.extend({ return { created, last_rotated, versions: versionsArray }; }, + async fetchTotpKey(backend, name) { + const resp = await this.api.secrets.totpReadKey(name, backend); + const data = resp.data || {}; + return new TotpKeyForm( + { + ...data, + name, + backend, + }, + { isNew: false } + ); + }, + + async fetchTotpKeyCapabilities(backend, name) { + const keyPath = this.capabilitiesService.pathFor('totpKey', { backend, name }); + const keysPath = this.capabilitiesService.pathFor('totpKeys', { backend }); + + const capabilities = await this.capabilitiesService.fetch([keyPath, keysPath]); + + return { + canDelete: capabilities[keyPath]?.canDelete, + canRead: capabilities[keyPath]?.canRead, + canList: capabilities[keysPath]?.canList, + }; + }, + async fetchKeymgmtKey(backend, name) { const { data } = await this.api.secrets.keyManagementReadKey(name, backend); @@ -344,8 +371,11 @@ export default Route.extend({ let transformRoles; let capabilities; - // Handle keymgmt resources with API service - if (modelType === 'keymgmt/key') { + // Handle TOTP resources with API service + if (modelType === 'totp-key') { + secretModel = await this.fetchTotpKey(backend, secret); + capabilities = await this.fetchTotpKeyCapabilities(backend, secret); + } else if (modelType === 'keymgmt/key') { secretModel = await this.fetchKeymgmtKey(backend, secret); capabilities = await this.fetchKeymgmtKeyCapabilities(backend, secret); } else if (modelType === 'keymgmt/provider') { @@ -389,15 +419,16 @@ export default Route.extend({ // mode will be 'show', 'edit', 'create' const mode = this.routeName.split('.').pop().replace('-root', ''); - // Handle keymgmt forms differently - Resource or Form doesn't have setProperties + // Handle keymgmt and TOTP forms differently - Resource or Form doesn't have setProperties const modelType = this.modelType(backend, secret); - if (!['keymgmt/key', 'keymgmt/provider'].includes(modelType)) { + const formModelTypes = ['keymgmt/key', 'keymgmt/provider', 'totp-key']; + if (!formModelTypes.includes(modelType)) { model.secret.setProperties({ backend }); } controller.setProperties({ model: model.secret, - form: ['keymgmt/key', 'keymgmt/provider'].includes(modelType) ? model.secret : null, + form: formModelTypes.includes(modelType) ? model.secret : null, capabilities: model.capabilities, baseKey: { id: secret }, mode, diff --git a/ui/app/serializers/totp-key.js b/ui/app/serializers/totp-key.js deleted file mode 100644 index e2a660d2c8..0000000000 --- a/ui/app/serializers/totp-key.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2025 - * SPDX-License-Identifier: BUSL-1.1 - */ - -import ApplicationSerializer from './application'; -import { camelize } from '@ember/string'; - -export default class TotpKeySerializer extends ApplicationSerializer { - normalizeItems(payload, requestType) { - if ( - requestType !== 'queryRecord' && - payload.data && - payload.data.keys && - Array.isArray(payload.data.keys) - ) { - // if we have data.keys, it's a list of ids, so we map over that - // and create objects with id's - return payload.data.keys.map((secret) => ({ - id: secret, - backend: payload.backend, - })); - } - - Object.assign(payload, payload.data); - delete payload.data; - return payload; - } - - serialize(snapshot) { - // remove all fields that are not relevant to specified key provider - const { keyFormFields } = snapshot.adapterOptions; - const json = super.serialize(...arguments); - Object.keys(json).forEach((key) => { - if (!keyFormFields.includes(camelize(key))) { - delete json[key]; - } - }); - - // remove name as it isn't a parameter - it is a part of the request url - delete json.name; - return json; - } -} diff --git a/ui/app/utils/constants/capabilities.ts b/ui/app/utils/constants/capabilities.ts index fa75fb4fcb..a7e21e320d 100644 --- a/ui/app/utils/constants/capabilities.ts +++ b/ui/app/utils/constants/capabilities.ts @@ -84,4 +84,6 @@ export const PATH_MAP = { syncDestination: apiPath`sys/sync/destinations/${'type'}/${'name'}`, syncRemoveAssociation: apiPath`sys/sync/destinations/${'type'}/${'name'}/associations/remove`, syncSetAssociation: apiPath`sys/sync/destinations/${'type'}/${'name'}/associations/set`, + totpKey: apiPath`${'backend'}/keys/${'name'}`, + totpKeys: apiPath`${'backend'}/keys`, }; diff --git a/ui/tests/acceptance/secrets/backend/totp/key-test.js b/ui/tests/acceptance/secrets/backend/totp/key-test.js index a2b4e8e82a..ae89ba2638 100644 --- a/ui/tests/acceptance/secrets/backend/totp/key-test.js +++ b/ui/tests/acceptance/secrets/backend/totp/key-test.js @@ -19,13 +19,13 @@ module('Acceptance | totp key backend', function (hooks) { const createVaultKey = async (keyName, issuer, accountName, exported = true, qrSize = 200) => { await fillIn(GENERAL.inputByAttr('name'), keyName); await fillIn(GENERAL.inputByAttr('issuer'), issuer); - await fillIn(GENERAL.inputByAttr('accountName'), accountName); + await fillIn(GENERAL.inputByAttr('account_name'), accountName); if (!exported) { await click(GENERAL.toggleInput('toggle-exported')); } if (qrSize !== 200) { await click(GENERAL.button('Provider Options')); - await fillIn(GENERAL.inputByAttr('qrSize'), qrSize); + await fillIn(GENERAL.inputByAttr('qr_size'), qrSize); } await click(GENERAL.submitButton); }; @@ -34,7 +34,7 @@ module('Acceptance | totp key backend', function (hooks) { await click(GENERAL.radioByAttr('Other service')); await fillIn(GENERAL.inputByAttr('name'), keyName); await fillIn(GENERAL.inputByAttr('issuer'), issuer); - await fillIn(GENERAL.inputByAttr('accountName'), accountName); + await fillIn(GENERAL.inputByAttr('account_name'), accountName); if (url) await fillIn(GENERAL.inputByAttr('url'), url); if (key) await fillIn(GENERAL.inputByAttr('key'), key); await click(GENERAL.submitButton); diff --git a/ui/tests/integration/components/totp/key-form-test.js b/ui/tests/integration/components/totp/key-form-test.js index 6689b914c0..d7524ddfdf 100644 --- a/ui/tests/integration/components/totp/key-form-test.js +++ b/ui/tests/integration/components/totp/key-form-test.js @@ -9,13 +9,15 @@ import { render, fillIn, click } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import TotpKeyForm from 'vault/forms/totp/key'; +import sinon from 'sinon'; module('Integration | Component | totp/key-form', function (hooks) { setupRenderingTest(hooks); setupMirage(hooks); hooks.beforeEach(function () { - this.store = this.owner.lookup('service:store'); + this.api = this.owner.lookup('service:api'); this.root = { label: 'totp', text: 'totp', @@ -24,38 +26,35 @@ module('Integration | Component | totp/key-form', function (hooks) { }; }); - test('it should save new key generated by Vault', async function (assert) { - assert.expect(7); + hooks.afterEach(function () { + sinon.restore(); + }); - this.server.post('/totp/keys/test-key', (schema, req) => { - assert.ok(true, 'Request made to save key'); - const payload = JSON.parse(req.requestBody); - const expected = { - account_name: 'test-account', + test('it should save new key generated by Vault', async function (assert) { + assert.expect(15); + + const totpCreateKeyStub = sinon.stub(this.api.secrets, 'totpCreateKey').resolves({ data: {} }); + + this.form = new TotpKeyForm( + { + backend: 'totp', + generate: true, algorithm: 'SHA1', digits: 6, - exported: true, - generate: true, - issuer: 'test-issuer', - key_size: 20, period: 30, - qr_size: 200, + exported: true, + key_size: 20, skew: 1, - }; + qr_size: 200, + }, + { isNew: true } + ); - assert.deepEqual( - payload, - expected, - 'POST request made with correct properties when creating a key generated by Vault' - ); - }); - - this.model = this.store.createRecord('totp-key', { backend: 'totp', id: 'totp-test' }); await render(hbs` `); @@ -70,51 +69,66 @@ module('Integration | Component | totp/key-form', function (hooks) { assert .dom(GENERAL.validationErrorByAttr('issuer')) .hasText( - `Issuer can't be blank when when the key is generated by Vault.`, + `Issuer can't be blank when the key is generated by Vault.`, 'Validation messages are shown for issuer' ); assert - .dom(GENERAL.validationErrorByAttr('accountName')) + .dom(GENERAL.validationErrorByAttr('account_name')) .hasText( `Account name can't be blank when the key is generated by Vault.`, 'Validation messages are shown for account name' ); - assert.dom(GENERAL.inlineAlert).hasText('There are 3 errors with this form.', 'Renders form error count'); await fillIn('[data-test-input="name"]', 'test-key'); await fillIn('[data-test-input="issuer"]', 'test-issuer'); - await fillIn('[data-test-input="accountName"]', 'test-account'); + await fillIn('[data-test-input="account_name"]', 'test-account'); await click(GENERAL.submitButton); + + assert.ok(totpCreateKeyStub.calledOnce, 'totpCreateKey was called to save the key'); + + const [name, backend, data] = totpCreateKeyStub.firstCall.args; + assert.strictEqual(name, 'test-key', 'called with correct name'); + assert.strictEqual(backend, 'totp', 'called with correct backend'); + + // Verify Vault-generated key payload includes required fields + assert.strictEqual(data.issuer, 'test-issuer', 'data includes issuer'); + assert.strictEqual(data.account_name, 'test-account', 'data includes account_name'); + assert.true(data.generate, 'data includes generate flag'); + assert.true(data.exported, 'data includes exported'); + assert.strictEqual(data.key_size, 20, 'data includes key_size'); + + // Verify excluded fields for Vault-generated keys + assert.false('url' in data, 'data does not include url'); + assert.false('key' in data, 'data does not include key'); + assert.false('name' in data, 'data does not include name (passed separately)'); }); test('it should save new key that is not generated by Vault', async function (assert) { - assert.expect(6); + assert.expect(15); - this.server.post('/totp/keys/test-key', (schema, req) => { - assert.ok(true, 'Request made to save key'); - const payload = JSON.parse(req.requestBody); - const expected = { + const totpCreateKeyStub = sinon.stub(this.api.secrets, 'totpCreateKey').resolves({ data: {} }); + + this.form = new TotpKeyForm( + { + backend: 'totp', + generate: true, algorithm: 'SHA1', digits: 6, - generate: false, - key: 'test-root-key', period: 30, - }; - assert.deepEqual( - payload, - expected, - 'POST request made with correct properties when creating a key that is not generated by Vault' - ); - }); - - this.model = this.store.createRecord('totp-key', { backend: 'totp', id: 'totp-test' }); + exported: true, + key_size: 20, + skew: 1, + qr_size: 200, + }, + { isNew: true } + ); await render(hbs` `); @@ -133,28 +147,57 @@ module('Integration | Component | totp/key-form', function (hooks) { .dom(GENERAL.validationErrorByAttr('key')) .hasText( `Key can't be blank if key is being passed from another service and the URL is empty.`, - 'Validation messages are shown for issuer' + 'Validation messages are shown for key' ); - assert.dom(GENERAL.inlineAlert).hasText('There are 2 errors with this form.', 'Renders form error count'); await fillIn('[data-test-input="name"]', 'test-key'); await fillIn('[data-test-input="key"]', 'test-root-key'); await click(GENERAL.submitButton); + + assert.ok(totpCreateKeyStub.calledOnce, 'totpCreateKey was called to save the key'); + + const [name, backend, data] = totpCreateKeyStub.firstCall.args; + assert.strictEqual(name, 'test-key', 'called with correct name'); + assert.strictEqual(backend, 'totp', 'called with correct backend'); + + // Verify non-Vault-generated key payload includes required fields + assert.strictEqual(data.key, 'test-root-key', 'data includes key'); + assert.false(data.generate, 'data includes generate flag set to false'); + assert.strictEqual(data.algorithm, 'SHA1', 'data includes algorithm'); + assert.strictEqual(data.digits, 6, 'data includes digits'); + + // Verify excluded fields for non-Vault-generated keys + assert.false('issuer' in data, 'data does not include issuer'); + assert.false('account_name' in data, 'data does not include account_name'); + assert.false('exported' in data, 'data does not include exported'); + assert.false('key_size' in data, 'data does not include key_size'); + assert.false('name' in data, 'data does not include name (passed separately)'); }); test('it should toggle groups according to generate', async function (assert) { assert.expect(4); - this.model = this.store.createRecord('totp-key', { backend: 'totp', id: 'totp-test' }); - this.onSubmit = () => assert.ok(true, 'onSubmit callback fires on save success'); + this.form = new TotpKeyForm( + { + backend: 'totp', + generate: true, + algorithm: 'SHA1', + digits: 6, + period: 30, + exported: true, + key_size: 20, + skew: 1, + qr_size: 200, + }, + { isNew: true } + ); await render(hbs` `); @@ -169,4 +212,91 @@ module('Integration | Component | totp/key-form', function (hooks) { assert.dom(GENERAL.button('TOTP Code Options')).exists('Common group is shown'); assert.dom(GENERAL.button('Provider Options')).doesNotExist('Generated exclusive group is not shown'); }); + + test('it should show whitespace warnings but allow submission', async function (assert) { + assert.expect(9); + + const totpCreateKeyStub = sinon.stub(this.api.secrets, 'totpCreateKey').resolves({ data: {} }); + this.onSubmit = () => {}; + + this.form = new TotpKeyForm( + { + backend: 'totp', + generate: true, + algorithm: 'SHA1', + digits: 6, + period: 30, + exported: true, + key_size: 20, + skew: 1, + qr_size: 200, + }, + { isNew: true } + ); + + this.modelValidations = null; + + await render(hbs` + + `); + + await fillIn('[data-test-input="name"]', 'test key'); + await fillIn('[data-test-input="issuer"]', 'test-issuer'); + await fillIn('[data-test-input="account_name"]', 'test account'); + + // Manually trigger validation to check for warnings + const { isValid, state } = this.form.toJSON(); + this.set('modelValidations', state); + + // Re-render to show validations + await render(hbs` + + `); + + // Verify whitespace warnings are shown + assert.true(isValid, 'form is valid despite warnings'); + assert + .dom(GENERAL.validationWarningByAttr('name')) + .hasText( + "Name contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.", + 'Warning message shown for name with whitespace' + ); + assert + .dom(GENERAL.validationWarningByAttr('account_name')) + .hasText( + "Account name contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.", + 'Warning message shown for account_name with whitespace' + ); + + // Re-render with TotpEdit to test actual submission + await render(hbs` + + `); + + await click(GENERAL.submitButton); + + // Verify form submission proceeds despite warnings + assert.ok(totpCreateKeyStub.calledOnce, 'totpCreateKey was called despite whitespace warnings'); + + const [name, backend, data] = totpCreateKeyStub.firstCall.args; + assert.strictEqual(name, 'test key', 'name with whitespace is passed'); + assert.strictEqual(backend, 'totp', 'called with correct backend'); + + // Verify whitespace values are included in payload + assert.strictEqual(data.issuer, 'test-issuer', 'data includes issuer'); + assert.strictEqual(data.account_name, 'test account', 'account_name with whitespace is included'); + assert.strictEqual(data.algorithm, 'SHA1', 'data includes algorithm'); + }); }); diff --git a/ui/tests/unit/decorators/model-form-fields-test.js b/ui/tests/unit/decorators/model-form-fields-test.js index 2ca54660ad..c298dec7fc 100644 --- a/ui/tests/unit/decorators/model-form-fields-test.js +++ b/ui/tests/unit/decorators/model-form-fields-test.js @@ -4,16 +4,17 @@ */ import { module, test } from 'qunit'; -import { setupApplicationTest } from 'ember-qunit'; +import { setupTest } from 'ember-qunit'; import { withFormFields } from 'vault/decorators/model-form-fields'; +import Model, { attr } from '@ember-data/model'; +import { run } from '@ember/runloop'; import sinon from 'sinon'; module('Unit | Decorators | ModelFormFields', function (hooks) { - setupApplicationTest(hooks); + setupTest(hooks); hooks.beforeEach(function () { this.spy = sinon.spy(console, 'error'); - this.store = this.owner.lookup('service:store'); }); hooks.afterEach(function () { this.spy.restore(); @@ -27,119 +28,43 @@ module('Unit | Decorators | ModelFormFields', function (hooks) { assert.ok(this.spy.calledWith(message), 'Error is printed to console'); }); - test('it returns allFields when arguments not provided', function (assert) { - assert.expect(1); - // test by instantiating a record that uses this decorator - const record = this.store.createRecord('totp-key'); - assert.deepEqual( - record.allFields, - [ - { - name: 'backend', - options: { readOnly: true }, - type: 'string', - }, - { - name: 'name', - options: { - subText: 'Specifies the name for this key.', - }, - type: 'string', - }, - { - name: 'accountName', - options: { - subText: 'The name of the account associated with the key. Required for keys generated by Vault.', - }, - type: 'string', - }, - { - name: 'algorithm', - options: { possibleValues: ['SHA1', 'SHA256', 'SHA512'], defaultValue: 'SHA1' }, - type: 'string', - }, - { - name: 'digits', - options: { possibleValues: [6, 8], defaultValue: 6 }, - type: 'number', - }, - { - name: 'issuer', - options: { - subText: `The name of the key's issuing organization. Required for keys generated by Vault.`, - }, - type: 'string', - }, - { - name: 'period', - options: { - editType: 'ttl', - helperTextEnabled: 'How long each generated TOTP is valid.', - defaultValue: 30, - }, - type: undefined, - }, - { - name: 'generate', - options: { - label: 'Key Provider', - defaultValue: true, - editType: 'radio', - possibleValues: ['Vault', 'Other service'], - fieldValue: 'generateString', - subText: 'Specifies if the key should be generated by Vault or passed from another service.', - }, - type: undefined, - }, - { - name: 'keySize', - options: { defaultValue: 20 }, - type: 'number', - }, - { - name: 'skew', - options: { possibleValues: [0, 1], defaultValue: 1 }, - type: 'number', - }, - { - name: 'exported', - options: { - editType: 'toggleButton', - defaultValue: true, - helperTextDisabled: 'Vault will not return QR code and url upon key creation.', - helperTextEnabled: 'QR code and URL will be returned upon generating a key.', - }, - type: 'boolean', - }, - { - name: 'qrSize', - options: { label: 'QR size', defaultValue: 200 }, - type: 'number', - }, - { - name: 'url', - options: { - label: 'URL', - helpText: - 'If a URL is provided the other fields can be left empty. E.g. otpauth://totp/Vault:test@test.com?secret=&issuer=Vault', - subText: 'The TOTP key url string that can be used to configure a key.', - }, - type: 'string', - }, - { - name: 'key', - options: { - subText: 'The root key used to generate a TOTP code.', - }, - type: 'string', - }, - { - name: 'barcode', - options: { readOnly: true }, - type: 'string', - }, - ], - 'allFields set on Model class' + test('it sets formFields and allFields when applied to a Model subclass', function (assert) { + @withFormFields(['name', 'role']) + class TestModel extends Model { + @attr('string', { label: 'Name' }) name; + @attr('string', { label: 'Role' }) role; + } + this.owner.register('model:test-with-form-fields', TestModel); + + const model = run(() => this.owner.lookup('service:store').createRecord('test-with-form-fields')); + + assert.ok(Array.isArray(model.formFields), 'formFields is set as an array'); + assert.strictEqual(model.formFields.length, 2, 'formFields contains the specified fields'); + assert.strictEqual(model.formFields[0].name, 'name', 'first formField is name'); + assert.strictEqual(model.formFields[1].name, 'role', 'second formField is role'); + + assert.ok(Array.isArray(model.allFields), 'allFields is set as an array'); + assert.strictEqual(model.allFields.length, 2, 'allFields contains all model attributes'); + }); + + test('it sets formFieldGroups when groupPropertyNames are provided', function (assert) { + @withFormFields(['name'], [{ default: ['name'] }, { Options: ['role'] }]) + class GroupedModel extends Model { + @attr('string') name; + @attr('string') role; + } + this.owner.register('model:test-with-form-field-groups', GroupedModel); + + const model = run(() => this.owner.lookup('service:store').createRecord('test-with-form-field-groups')); + + assert.ok(Array.isArray(model.formFieldGroups), 'formFieldGroups is set as an array'); + assert.ok( + model.formFieldGroups.some((g) => Object.prototype.hasOwnProperty.call(g, 'default')), + 'formFieldGroups contains a default group' + ); + assert.ok( + model.formFieldGroups.some((g) => Object.prototype.hasOwnProperty.call(g, 'Options')), + 'formFieldGroups contains the Options group' ); }); }); diff --git a/ui/tests/unit/services/path-help-test.js b/ui/tests/unit/services/path-help-test.js index 86cf21ceee..483dbd10ae 100644 --- a/ui/tests/unit/services/path-help-test.js +++ b/ui/tests/unit/services/path-help-test.js @@ -97,7 +97,7 @@ module('Unit | Service | path-help', function (hooks) { assert.notOk(true, 'this method should not be called'); return reject(); }); - const modelType = 'totp-key'; + const modelType = 'cluster'; await this.pathHelp.getNewModel(modelType, 'my-kv').then(() => { assert.true(true, 'getNewModel resolves'); });