[UI] Ember Data Migration - TOTP Secrets Engine Views | VAULT-44225 (#14933) (#14983)

* VAULT-44225 - edm secrets totp views

* fixed review comments and updated validations to match original

* fixed review comments

Co-authored-by: mohit-hashicorp <mohit.ojha@hashicorp.com>
This commit is contained in:
Vault Automation 2026-05-26 00:01:44 -06:00 committed by GitHub
parent 19071d59a6
commit 3f2f491f1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 582 additions and 622 deletions

View file

@ -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;
});
}
}

View file

@ -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;

View file

@ -6,7 +6,7 @@
<Page::Header @title={{this.title}} @subtitle={{this.subtitle}}>
<:breadcrumbs>
<KeyValueHeader
@baseKey={{@model}}
@baseKey={{@form}}
@path="vault.cluster.secrets.backend.list"
@mode={{@mode}}
@root={{this.breadcrumbs}}
@ -16,16 +16,24 @@
</Page::Header>
{{#if (eq @mode "show")}}
<Totp::KeyDetails @model={{@model}} @onDelete={{this.deleteKey}} />
<Totp::KeyDetails
@key={{this.key}}
@capabilities={{@capabilities}}
@onDelete={{this.deleteKey}}
@displayFields={{this.displayFields}}
/>
{{else if (eq @mode "create")}}
{{#if this.hasGenerated}}
<Totp::KeyQrCode @model={{@model}} @onReset={{this.reset}} />
<Totp::KeyQrCode
@qrSize={{@form.data.qr_size}}
@key={{this.key}}
@onReset={{this.reset}}
@generatedFields={{this.generatedFields}}
/>
{{else}}
<Totp::KeyCreate
@onSubmit={{this.createKey}}
@model={{@model}}
@defaultKeyFormFields={{this.defaultKeyFormFields}}
@groups={{this.groups}}
@form={{@form}}
@modelValidations={{this.modelValidations}}
@invalidFormAlert={{this.invalidFormAlert}}
/>

View file

@ -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
* <TotpEdit @model={{this.model}} @mode={{this.mode}} />
* <TotpEdit @form={{this.form}} @mode={{this.mode}} @capabilities={{this.capabilities}} />
*
* @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.');
})
);
}

View file

@ -1,31 +0,0 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
{{#each-in @groups as |group fields|}}
<ToggleButton
@isOpen={{eq this.showGroup group}}
@openLabel={{concat "Hide " group}}
@closedLabel={{group}}
@onClick={{fn this.toggleGroup group}}
class="is-block"
data-test-button={{group}}
/>
{{#if (eq this.showGroup group)}}
<div class="box is-marginless" data-test-group={{group}}>
{{#each fields as |fieldName|}}
{{#let (find-by "name" fieldName @model.allFields) as |attr|}}
<FormField
data-test-field
@attr={{attr}}
@model={{@model}}
@showHelpText={{false}}
@modelValidations={{@modelValidations}}
/>
{{/let}}
{{/each}}
</div>
{{/if}}
{{/each-in}}

View file

@ -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;
}
}

View file

@ -5,15 +5,8 @@
<form {{on "submit" (perform @onSubmit)}}>
<div class="box is-sideless is-fullwidth is-marginless">
<MessageError @model={{@model}} />
<NamespaceReminder @mode={{"create"}} @noun="TOTP key" />
{{#each @defaultKeyFormFields as |field|}}
{{#let (find-by "name" field @model.allFields) as |attr|}}
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{@modelValidations}} />
{{/let}}
{{/each}}
<Totp::KeyCreateToggleGroups @model={{@model}} @modelValidations={{@modelValidations}} @groups={{@groups}} />
<FormFieldGroups @model={{@form}} @groupName="formFieldGroups" @modelValidations={{@modelValidations}} />
</div>
<Hds::ButtonSet class="has-top-bottom-margin-12">
<Hds::Button @text="Create key" type="submit" data-test-submit />
@ -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"}}
/>
</Hds::ButtonSet>

View file

@ -5,10 +5,10 @@
<Toolbar>
<ToolbarActions>
<ToolbarLink @route="vault.cluster.secrets.backend.credentials" @model={{@model.id}}>
<ToolbarLink @route="vault.cluster.secrets.backend.credentials" @model={{@key.name}}>
Generate code
</ToolbarLink>
{{#if @model.canDelete}}
{{#if @capabilities.canDelete}}
<ConfirmAction
@buttonText="Delete key"
class="toolbar-button"
@ -19,10 +19,7 @@
</ToolbarActions>
</Toolbar>
<div class="box is-fullwidth is-sideless is-paddingless is-marginless" data-test-totp-key-details>
{{#each @model.attrs as |attr|}}
<InfoTableRow
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{get @model attr.name}}
/>
{{#each @displayFields as |item|}}
<InfoTableRow @label={{item.label}} @value={{get @key item.field}} />
{{/each}}
</div>

View file

@ -4,36 +4,29 @@
}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
<MessageError @model={{@model}} />
{{#unless @model.isError}}
<Hds::Alert @type="inline" @color="warning" class="has-top-bottom-margin" data-test-warning as |A|>
<A.Title>Warning</A.Title>
<A.Description>
You will not be able to access this information later, so please copy the information below.
</A.Description>
</Hds::Alert>
{{/unless}}
{{#each @model.generatedAttrs as |attr|}}
<InfoTableRow
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{get @model attr.name}}
/>
<Hds::Alert @type="inline" @color="warning" class="has-top-bottom-margin" data-test-warning as |A|>
<A.Title>Warning</A.Title>
<A.Description>
You will not be able to access this information later, so please copy the information below.
</A.Description>
</Hds::Alert>
{{#each @generatedFields as |item|}}
<InfoTableRow @label={{item.label}} @value={{get @key item.field}} />
{{/each}}
{{#if (gt @model.qrSize 0)}}
{{#if (gt @qrSize 0)}}
<div class="list-item-row">
<div class="center-display column">
<QrCode
@text={{@model.url}}
@text={{@key.url}}
@colorLight="#F7F7F7"
@width={{@model.qrSize}}
@height={{@model.qrSize}}
@width={{@qrSize}}
@height={{@qrSize}}
@correctLevel="L"
data-test-qrcode
/>
</div>
</div>
{{/if}}
</div>
<div class="field is-grouped box is-fullwidth is-bottomless">
<div class="control">

View file

@ -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

173
ui/app/forms/totp/key.ts Normal file
View file

@ -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<TotpKeyData> {
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=<your_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<string, unknown>;
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 };
}
}

View file

@ -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=<your_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');
}
}

View file

@ -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' });
}

View file

@ -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 {};
}
},

View file

@ -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 =

View file

@ -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,

View file

@ -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;
}
}

View file

@ -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`,
};

View file

@ -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);

View file

@ -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`
<TotpEdit
@mode="create"
@root={{this.root}}
@model={{this.model}}
@form={{this.form}}
/>
`);
@ -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`
<TotpEdit
@mode="create"
@root={{this.root}}
@model={{this.model}}
@form={{this.form}}
/>
`);
@ -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`
<TotpEdit
@mode="create"
@root={{this.root}}
@onSubmit={{this.onSubmit}}
@model={{this.model}}
@form={{this.form}}
/>
`);
@ -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`
<TotpEdit
@mode="create"
@root={{this.root}}
@form={{this.form}}
/>
`);
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`
<Totp::KeyCreate
@onSubmit={{this.onSubmit}}
@form={{this.form}}
@modelValidations={{this.modelValidations}}
/>
`);
// 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`
<TotpEdit
@mode="create"
@root={{this.root}}
@form={{this.form}}
/>
`);
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');
});
});

View file

@ -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=<your_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'
);
});
});

View file

@ -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');
});