Backport [UI] Ember Data Migration - Transform secrets engine code cleanup | VAULT-45710 into ce/main (#15307)

* no-op commit

* clean up transform-related components and add tests for alphabet-edit and transformation-edit

* fixed failing tests

* removed redundant test file

---------

Co-authored-by: Mohit Ojha <mohit.ojha@hashicorp.com>
This commit is contained in:
Vault Automation 2026-06-09 08:02:49 -06:00 committed by GitHub
parent 6e6396a65b
commit b14c6ea83f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 331 additions and 621 deletions

View file

@ -2,7 +2,7 @@
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<Page::Header @title={{this.title}}>
<Page::Header @title={{this.title}} @subtitle={{this.subtitle}}>
<:breadcrumbs>
<Page::Breadcrumbs @breadcrumbs={{this.breadcrumbs}} />
</:breadcrumbs>
@ -12,7 +12,13 @@
<Toolbar>
<ToolbarActions>
{{#if @capabilities.canDelete}}
<Hds::Button @text="Delete alphabet" @color="secondary" class="toolbar-button" {{on "click" this.onDelete}} />
<Hds::Button
@text="Delete alphabet"
@color="secondary"
class="toolbar-button"
data-test-delete
{{on "click" this.onDelete}}
/>
<div class="toolbar-separator"></div>
{{/if}}
{{#if @capabilities.canUpdate}}

View file

@ -29,29 +29,39 @@ export default class AlphabetEditComponent extends Component {
@tracked errorMessage = '';
get breadcrumbs() {
// ideally this is created on the controller in the parent route but this is a generic route and adding breadcrumbs to the controller requires a larger refactor.
const backend = this.args.form?.data?.backend;
const name = this.args.form?.data?.name;
return [
{ label: 'Vault', route: 'vault.cluster.dashboard', icon: 'vault' },
{ label: 'Secrets engines', route: 'vault.cluster.secrets.backends' },
{
label: backend,
route: 'vault.cluster.secrets.backend.list-root',
model: backend,
query: { tab: 'alphabet' },
},
{ label: 'Alphabet' },
{ label: this.title },
{ label: this.args?.mode === 'create' ? 'alphabet' : name },
];
}
get title() {
if (this.args?.mode === 'create') {
return 'Create Alphabet';
return 'Create alphabet';
} else if (this.args?.mode === 'edit') {
return 'Edit Alphabet';
return 'Edit alphabet';
} else {
return this.args?.form?.data?.name;
return 'Alphabet';
}
}
get subtitle() {
if (this.args?.mode === 'show') {
return this.args.form?.data?.name;
}
return '';
}
transition(route = 'show') {
this.errorMessage = '';
const { backend, name } = this.args.form.data;

View file

@ -1,25 +0,0 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<form onsubmit={{action "createOrUpdate" "create"}}>
<div class="box is-sideless is-fullwidth is-marginless">
<MessageError @model={{this.model}} />
<NamespaceReminder @mode={{this.mode}} @noun="transformation" />
{{#each this.model.transformFieldAttrs as |attr|}}
<FormField data-test-field @attr={{attr}} @model={{this.model}} />
{{/each}}
</div>
<div class="field is-grouped-split box is-fullwidth is-bottomless">
<Hds::ButtonSet>
<Hds::Button @text="Create transformation" type="submit" data-test-submit />
<Hds::Button
@text="Cancel"
@color="secondary"
@route="vault.cluster.secrets.backend.list-root"
@model={{this.model.backend}}
/>
</Hds::ButtonSet>
</div>
</form>

View file

@ -1,8 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import TransformationEdit from './transformation-edit';
export default TransformationEdit.extend({});

View file

@ -1,136 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { service } from '@ember/service';
import { or } from '@ember/object/computed';
import { isBlank } from '@ember/utils';
import Component from '@ember/component';
import { set } from '@ember/object';
import FocusOnInsertMixin from 'vault/mixins/focus-on-insert';
const LIST_ROOT_ROUTE = 'vault.cluster.secrets.backend.list-root';
const SHOW_ROUTE = 'vault.cluster.secrets.backend.show';
export const addToList = (list, itemToAdd) => {
if (!list || !Array.isArray(list)) return list;
list.push(itemToAdd);
return list.uniq();
};
export const removeFromList = (list, itemToRemove) => {
if (!list) return list;
const index = list.indexOf(itemToRemove);
if (index < 0) return list;
const newList = list.removeAt(index, 1);
return newList.uniq();
};
/**
* @type Class
*/
export default Component.extend(FocusOnInsertMixin, {
store: service(),
flashMessages: service(),
router: service(),
mode: null,
onDataChange() {},
onRefresh() {},
model: null,
requestInFlight: or('model.isLoading', 'model.isReloading', 'model.isSaving'),
init() {
this._super(...arguments);
this.set('backendType', 'transform');
},
willDestroyElement() {
if (this.model && this.model.isError && !this.model.isDestroyed && !this.model.isDestroying) {
this.model.rollbackAttributes();
}
this._super(...arguments);
},
transitionToRoute() {
this.router.transitionTo(...arguments);
},
modelPrefixFromType(modelType) {
let modelPrefix = '';
if (modelType && modelType.startsWith('transform/')) {
modelPrefix = `${modelType.replace('transform/', '')}/`;
}
return modelPrefix;
},
listTabFromType(modelType) {
let tab;
if (modelType && modelType.startsWith('transform/')) {
tab = `${modelType.replace('transform/', '')}`;
}
return tab;
},
persist(method, successCallback) {
const model = this.model;
return model[method]()
.then(() => {
successCallback(model);
})
.catch((e) => {
model.set('displayErrors', e.errors);
throw e;
});
},
applyDelete(callback = () => {}) {
const tab = this.listTabFromType(this.model.constructor.modelName);
this.persist('destroyRecord', () => {
this.hasDataChanges();
callback();
this.transitionToRoute(LIST_ROOT_ROUTE, { queryParams: { tab } });
});
},
applyChanges(type, callback = () => {}) {
const modelId = this.model.id || this.model.name; // transform comes in as model.name
const modelPrefix = this.modelPrefixFromType(this.model.constructor.modelName);
// prevent from submitting if there's no key
// maybe do something fancier later
if (type === 'create' && isBlank(modelId)) {
return;
}
this.persist('save', () => {
this.hasDataChanges();
callback();
this.transitionToRoute(SHOW_ROUTE, `${modelPrefix}${modelId}`);
});
},
hasDataChanges() {
this.onDataChange(this.model?.hasDirtyAttributes);
},
actions: {
createOrUpdate(type, event) {
event.preventDefault();
this.applyChanges(type);
},
setValue(key, event) {
set(this.model, key, event.target.checked);
},
refresh() {
this.onRefresh();
},
delete() {
this.applyDelete();
},
},
});

View file

@ -1,56 +0,0 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<form onsubmit={{action "createOrUpdate" "create"}}>
<div class="box is-sideless is-fullwidth is-marginless">
<MessageError @model={{this.model}} />
<NamespaceReminder @mode={{this.mode}} @noun="transformation" />
{{#each this.model.transformFieldAttrs as |attr|}}
{{#if (or (eq attr.name "name") (eq attr.name "type"))}}
<label for={{attr.name}} class="is-label">
{{attr.options.label}}
</label>
{{#if attr.options.subText}}
<p class="sub-text">{{attr.options.subText}}</p>
{{/if}}
{{#if attr.options.possibleValues}}
<div class="control is-expanded field is-readOnly">
<div class="select is-fullwidth">
<select name={{attr.name}} id={{attr.name}} disabled data-test-input={{attr.name}}>
<option selected={{get this.model attr.name}} value={{get this.model attr.name}}>
{{get this.model attr.name}}
</option>
</select>
</div>
</div>
{{else}}
<input
data-test-input={{attr.name}}
id={{attr.name}}
autocomplete="off"
spellcheck="false"
value={{or (get this.model attr.name) attr.options.defaultValue}}
readonly
class="field input is-readOnly"
type={{attr.type}}
/>
{{/if}}
{{else}}
<FormField data-test-field @attr={{attr}} @model={{this.model}} />
{{/if}}
{{/each}}
</div>
<div class="field is-grouped-split box is-fullwidth is-bottomless">
<Hds::ButtonSet>
<Hds::Button @text="Save" type="submit" data-test-submit />
<Hds::Button
@text="Cancel"
@color="secondary"
@route="vault.cluster.secrets.backend.show"
@models={{array this.model.backend this.model.id}}
/>
</Hds::ButtonSet>
</div>
</form>

View file

@ -1,8 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import TransformationEdit from './transformation-edit';
export default TransformationEdit.extend();

View file

@ -1,74 +0,0 @@
{{!
Copyright IBM Corp. 2016, 2025
SPDX-License-Identifier: BUSL-1.1
}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#each this.model.transformFieldAttrs as |attr|}}
{{#if (eq attr.type "object")}}
<InfoTableRow
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{stringify (get this.model attr.name)}}
/>
{{else if (eq attr.type "array")}}
<InfoTableRow
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{get this.model attr.name}}
@type={{attr.type}}
@isLink={{eq attr.name "allowed_roles"}}
@queryParam="role"
@arrayOptions={{if (eq attr.name "allowed_roles") @transformRoles null}}
@wildcardLabel={{if (eq attr.name "allowed_roles") attr.options.wildcardLabel null}}
/>
{{else}}
<InfoTableRow
@label={{capitalize (or attr.options.label (humanize (dasherize attr.name)))}}
@value={{get this.model attr.name}}
@type={{attr.type}}
/>
{{/if}}
{{/each}}
</div>
<div class="has-top-margin-xl has-bottom-margin-s">
<label class="title has-border-bottom-light page-header">CLI Commands</label>
<div class="has-bottom-margin-s">
<h2 class="title is-6">Encode</h2>
<div class="has-bottom-margin-s">
<span class="helper-text has-text-grey">
To test the encoding capability of your transformation, use the following command. It will output an encoded_value.
</span>
</div>
<div class="copy-text level">
{{#let (concat "vault write " this.model.backend "/encode/" this.cliCommand) as |copyEncodeCommand|}}
<Hds::CodeBlock
@language="bash"
@hasLineNumbers={{false}}
@hasLineWrapping={{true}}
@hasCopyButton={{true}}
@value={{copyEncodeCommand}}
/>
{{/let}}
</div>
</div>
<div>
<h2 class="title is-6">Decode</h2>
<div class="has-bottom-margin-s">
<span class="helper-text has-text-grey">
To test decoding capability of your transformation, use the encoded_value in the following command. It should return
your original input.
</span>
</div>
<div class="copy-text level">
{{#let (concat "vault write " this.model.backend "/decode/" this.cliCommand) as |copyDecodeCommand|}}
<Hds::CodeBlock
@language="bash"
@hasLineNumbers={{false}}
@hasLineWrapping={{true}}
@hasCopyButton={{true}}
@value={{copyDecodeCommand}}
/>
{{/let}}
</div>
</div>
</div>

View file

@ -1,34 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import TransformBase from './transform-edit-base';
import { computed } from '@ember/object';
export default TransformBase.extend({
cliCommand: computed('model.{allowed_roles,type,tweak_source}', function () {
if (!this.model) {
return;
}
const { type, allowed_roles, tweak_source, name } = this.model;
const wildCardRole = allowed_roles.find((role) => role.includes('*'));
// values to be returned
let role = '<choose a role>';
const value = 'value=<enter your value here>';
let tweak = '';
// determine the role
if (allowed_roles.length === 1 && !wildCardRole) {
role = allowed_roles[0];
}
// determine the tweak_source
if (type === 'fpe' && tweak_source === 'supplied') {
tweak = 'tweak=<enter your tweak>';
}
return `${role} ${value} ${tweak} transformation=${name}`;
}),
});

View file

@ -49,24 +49,27 @@ export default class TransformTemplateEditComponent extends Component {
}
get breadcrumbs() {
// ideally this is created on the controller in the parent route but this is a generic route and adding breadcrumbs to the controller requires a larger refactor.
const backend = this.args.form?.data?.backend;
const name = this.args.form?.data?.name;
return [
{ label: 'Vault', route: 'vault.cluster.dashboard', icon: 'vault' },
{ label: 'Secrets engines', route: 'vault.cluster.secrets.backends' },
{
label: backend,
route: 'vault.cluster.secrets.backend.list-root',
model: backend,
query: { tab: 'template' },
},
{ label: 'Template' },
{ label: this.title },
{ label: this.args.mode === 'create' ? 'template' : name },
];
}
get title() {
if (this.args.mode === 'create') {
return 'Create Template';
return 'Create template';
} else if (this.args.mode === 'edit') {
return 'Edit Template';
return 'Edit template';
} else {
return 'Template';
}

View file

@ -275,13 +275,19 @@ export default class TransformationEditComponent extends Component {
max_ttl,
stores,
});
this.flashMessages.success('Transformation saved.');
await this.handleRoleSync(name, backend);
this.transition();
} catch (e) {
const { message } = await this.api.parseError(e);
this.errorMessage = message;
return;
}
this.flashMessages.success('Transformation saved.');
// handleRoleSync handles its own errors internally with flash messages;
// guard against any unexpected throws to ensure navigation always happens after a successful save.
await this.handleRoleSync(name, backend).catch(() => {});
this.transition();
}
@action async onDelete() {

View file

@ -1,140 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import Model, { attr } from '@ember-data/model';
import { computed } from '@ember/object';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
// these arrays define the order in which the fields will be displayed, see:
// https://developer.hashicorp.com/vault/api-docs/secret/transform#create-update-transformation-deprecated-1-6
const TYPES = [
{
value: 'fpe',
displayName: 'Format Preserving Encryption (FPE)',
},
{
value: 'masking',
displayName: 'Masking',
},
{
value: 'tokenization',
displayName: 'Tokenization',
},
];
const TWEAK_SOURCE = [
{
value: 'supplied',
displayName: 'supplied',
},
{
value: 'generated',
displayName: 'generated',
},
{
value: 'internal',
displayName: 'internal',
},
];
export default Model.extend({
name: attr('string', {
// CBS TODO: make this required for making a transformation
label: 'Name',
readOnly: true,
subText: 'The name for your transformation. This cannot be edited later.',
}),
type: attr('string', {
defaultValue: 'fpe',
label: 'Type',
possibleValues: TYPES,
subText:
'Vault provides two types of transformations: Format Preserving Encryption (FPE) is reversible, while Masking is not. This cannot be edited later.',
}),
tweak_source: attr('string', {
defaultValue: 'supplied',
label: 'Tweak source',
possibleValues: TWEAK_SOURCE,
subText: `A tweak value is used when performing FPE transformations. This can be supplied, generated, or internal.`, // CBS TODO: I do not include the link here. Need to figure out the best way to approach this.
}),
masking_character: attr('string', {
characterLimit: 1,
defaultValue: '*',
label: 'Masking character',
subText: 'Specify which character youd like to mask your data.',
}),
template: attr('array', {
editType: 'searchSelect',
isSectionHeader: true,
fallbackComponent: 'string-list',
label: 'Template', // CBS TODO: make this required for making a transformation
models: ['transform/template'],
selectLimit: 1,
onlyAllowExisting: true,
subText:
'Templates allow Vault to determine what and how to capture the value to be transformed. Type to use an existing template or create a new one.',
}),
allowed_roles: attr('array', {
editType: 'searchSelect',
isSectionHeader: true,
label: 'Allowed roles',
fallbackComponent: 'string-list',
models: ['transform/role'],
subText: 'Search for an existing role, type a new role to create it, or use a wildcard (*).',
wildcardLabel: 'role',
}),
deletion_allowed: attr('boolean', {
label: 'Allow deletion',
subText:
'If checked, this transform can be deleted otherwise deletion is blocked. Note that deleting the transform deletes the underlying key which makes decoding of tokenized values impossible without restoring from a backup.',
}),
convergent: attr('boolean', {
label: 'Use convergent tokenization',
subText:
"This cannot be edited later. If checked, tokenization of the same plaintext more than once results in the same token. Defaults to false as unique tokens are more desirable from a security standpoint if there isn't a use-case need for convergence.",
}),
stores: attr('array', {
label: 'Stores',
editType: 'stringArray',
subText:
"The list of tokenization stores to use for tokenization state. Vault's internal storage is used by default.",
}),
mapping_mode: attr('string', {
defaultValue: 'default',
subText:
'Specifies the mapping mode for stored tokenization values. "default" is strongly recommended for highest security, "exportable" allows for all plaintexts to be decoded via the export-decoded endpoint in an emergency.',
}),
max_ttl: attr({
editType: 'ttl',
defaultValue: '0',
label: 'Maximum TTL (time-to-live) of a token',
helperTextDisabled: 'If "0" or unspecified, tokens may have no expiration.',
}),
transformAttrs: computed('type', function () {
// allowed_roles not included so it displays at the bottom of the form
const baseAttrs = ['name', 'type', 'deletion_allowed'];
switch (this.type) {
case 'fpe':
return [...baseAttrs, 'tweak_source', 'template', 'allowed_roles'];
case 'masking':
return [...baseAttrs, 'masking_character', 'template', 'allowed_roles'];
case 'tokenization':
return [...baseAttrs, 'mapping_mode', 'convergent', 'max_ttl', 'stores', 'allowed_roles'];
default:
return [...baseAttrs];
}
}),
transformFieldAttrs: computed('transformAttrs', function () {
return expandAttributeMeta(this, this.transformAttrs);
}),
backend: attr('string', {
readOnly: true,
}),
updatePath: lazyCapabilities(apiPath`${'backend'}/transformation/${'id'}`, 'backend', 'id'),
});

View file

@ -0,0 +1,106 @@
/**
* Copyright IBM Corp. 2016, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render, click, findAll } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import AlphabetForm from 'vault/forms/transform/alphabet';
import sinon from 'sinon';
import type ApiService from 'vault/services/api';
module('Integration | Component | alphabet-edit', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
const router = this.owner.lookup('service:router') as unknown as Record<string, unknown>;
router['transitionTo'] = sinon.stub();
this.set('capabilities', {
canDelete: true,
canUpdate: true,
canRead: true,
});
});
test('it renders in show mode', async function (assert) {
this.set(
'form',
new AlphabetForm(
{
name: 'my-alphabet',
alphabet: 'abcdefghijklmnopqrstuvwxyz',
backend: 'transform',
},
{ isNew: false }
)
);
this.set('mode', 'show');
await render(
hbs`<AlphabetEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
);
assert.dom('[data-test-edit-link]').exists('renders toolbar edit link');
assert.dom('[data-test-field]').doesNotExist('does not render form fields in show mode');
});
test('it renders in create mode', async function (assert) {
this.set('form', new AlphabetForm({ backend: 'transform' }, { isNew: true }));
this.set('mode', 'create');
await render(
hbs`<AlphabetEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
);
assert.dom('[data-test-submit]').exists('renders submit button');
assert.dom('[data-test-submit]').hasText('Create alphabet');
const fields = findAll('[data-test-field]');
assert.strictEqual(fields.length, 2, 'renders name and alphabet fields');
});
test('it renders in edit mode', async function (assert) {
this.set(
'form',
new AlphabetForm(
{
name: 'my-alphabet',
alphabet: 'abcdefghijklmnopqrstuvwxyz',
backend: 'transform',
},
{ isNew: false }
)
);
this.set('mode', 'edit');
await render(
hbs`<AlphabetEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
);
assert.dom('[data-test-submit]').exists('renders submit button');
assert.dom('[data-test-submit]').hasText('Save');
assert.dom('[data-test-input="name"]').hasAttribute('readonly', '', 'name is readonly in edit mode');
});
test('it calls onDelete and transitions to list', async function (assert) {
const api = this.owner.lookup('service:api') as unknown as ApiService;
const deleteStub = sinon.stub(api.secrets, 'transformDeleteAlphabet').resolves();
this.set('form', new AlphabetForm({ name: 'my-alphabet', backend: 'transform' }, { isNew: false }));
this.set('mode', 'show');
await render(
hbs`<AlphabetEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
);
await click('[data-test-delete]');
assert.ok(
deleteStub.calledWith('my-alphabet', 'transform'),
'calls transformDeleteAlphabet with correct args'
);
deleteStub.restore();
});
});

View file

@ -1,31 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
module('Integration | Component | transform-edit-base', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.set('myAction', function(val) { ... });
await render(hbs`<TransformEditBase />`);
assert.dom(this.element).hasText('');
// Template block usage:
await render(hbs`
<TransformEditBase>
template block text
</TransformEditBase>
`);
assert.dom(this.element).hasText('template block text');
});
});

View file

@ -0,0 +1,186 @@
/**
* Copyright IBM Corp. 2016, 2026
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render, click, fillIn, findAll } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import TransformationForm from 'vault/forms/transform/transformation';
import sinon from 'sinon';
import type ApiService from 'vault/services/api';
module('Integration | Component | transformation-edit', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
const router = this.owner.lookup('service:router') as unknown as Record<string, unknown>;
router['transitionTo'] = sinon.stub();
this.set('capabilities', {
canDelete: true,
canUpdate: true,
canRead: true,
});
// Stub list fetches called in constructor to avoid real API calls
const api = this.owner.lookup('service:api') as unknown as ApiService;
sinon.stub(api.secrets, 'transformListRoles').resolves({ keys: [] });
sinon.stub(api.secrets, 'transformListTemplates').resolves({ keys: [] });
});
hooks.afterEach(function () {
sinon.restore();
});
test('it renders in show mode', async function (assert) {
this.set(
'form',
new TransformationForm(
{
name: 'my-transformation',
type: 'fpe',
allowed_roles: [],
backend: 'transform',
},
{ isNew: false }
)
);
this.set('mode', 'show');
await render(
hbs`<TransformationEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
);
assert.dom('[data-test-edit-link]').exists('renders toolbar edit link');
assert.dom('[data-test-field]').doesNotExist('does not render form fields in show mode');
});
test('it renders in create mode with fpe type', async function (assert) {
this.set('form', new TransformationForm({ backend: 'transform', type: 'fpe' }, { isNew: true }));
this.set('mode', 'create');
await render(
hbs`<TransformationEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
);
assert.dom('[data-test-submit]').exists('renders submit button');
assert.dom('[data-test-submit]').hasText('Create transformation');
// fpe: name, type, deletion_allowed, tweak_source (FormField) + template, allowed_roles (SearchSelect with data-test-field)
const fields = findAll('[data-test-field]');
assert.strictEqual(fields.length, 6, 'renders 6 fields for fpe type (4 FormField + 2 SearchSelect)');
});
test('it renders masking-specific field for masking type', async function (assert) {
this.set('form', new TransformationForm({ backend: 'transform', type: 'masking' }, { isNew: true }));
this.set('mode', 'create');
await render(
hbs`<TransformationEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
);
// masking: name, type, deletion_allowed, masking_character (FormField) + template, allowed_roles (SearchSelect with data-test-field)
const fields = findAll('[data-test-field]');
assert.strictEqual(fields.length, 6, 'renders 6 fields for masking type (4 FormField + 2 SearchSelect)');
assert.dom('[data-test-input="masking_character"]').exists('renders masking_character field');
assert
.dom('[data-test-input="tweak_source"]')
.doesNotExist('does not render tweak_source for masking type');
});
test('it renders tokenization-specific fields for tokenization type', async function (assert) {
this.set('form', new TransformationForm({ backend: 'transform', type: 'tokenization' }, { isNew: true }));
this.set('mode', 'create');
await render(
hbs`<TransformationEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
);
// tokenization shows: name, type, deletion_allowed, mapping_mode, convergent, max_ttl, stores, allowed_roles (SearchSelect)
assert.dom('[data-test-input="mapping_mode"]').exists('renders mapping_mode field');
assert.dom('[data-test-input="convergent"]').exists('renders convergent field');
assert.dom('[data-test-input="max_ttl"]').exists('renders max_ttl field');
assert
.dom('[data-test-input="tweak_source"]')
.doesNotExist('does not render tweak_source for tokenization type');
assert
.dom('[data-test-input="masking_character"]')
.doesNotExist('does not render masking_character for tokenization type');
});
test('it renders in edit mode with name readonly', async function (assert) {
this.set(
'form',
new TransformationForm(
{
name: 'my-transformation',
type: 'fpe',
allowed_roles: [],
backend: 'transform',
},
{ isNew: false }
)
);
this.set('mode', 'edit');
await render(
hbs`<TransformationEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
);
assert.dom('[data-test-submit]').hasText('Save');
assert.dom('[data-test-input="name"]').hasAttribute('readonly', '', 'name is readonly in edit mode');
});
test('it calls onDelete and transitions to list', async function (assert) {
const api = this.owner.lookup('service:api') as unknown as ApiService;
const deleteStub = sinon.stub(api.secrets, 'transformDeleteTransformation').resolves();
this.set(
'form',
new TransformationForm(
{ name: 'my-transformation', type: 'fpe', allowed_roles: [], backend: 'transform' },
{ isNew: false }
)
);
this.set('mode', 'show');
await render(
hbs`<TransformationEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
);
await click('[data-test-delete]');
await fillIn('[data-test-confirmation-modal-input="Delete transformation"]', 'my-transformation');
await click('[data-test-confirm-button="Delete transformation"]');
assert.ok(
deleteStub.calledWith('my-transformation', 'transform'),
'calls transformDeleteTransformation with correct args'
);
});
test('it shows edit warning modal when transformation has allowed roles', async function (assert) {
this.set(
'form',
new TransformationForm(
{
name: 'my-transformation',
type: 'fpe',
allowed_roles: ['my-role'],
backend: 'transform',
},
{ isNew: false }
)
);
this.set('mode', 'show');
await render(
hbs`<TransformationEdit @form={{this.form}} @capabilities={{this.capabilities}} @mode={{this.mode}} />`
);
await click('[data-test-edit-link]');
assert.dom('#transformation-edit-modal').exists('shows edit warning modal when transformation has roles');
assert.dom('[data-test-edit-confirm-button]').exists('renders confirm button in modal');
});
});

View file

@ -1,95 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
const TRANSFORM_TYPES = ['fpe', 'masking', 'tokenization'];
module('Unit | Adapter | transform', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.backend = 'my-transform-engine';
this.name = 'my-transform';
});
hooks.afterEach(function () {
this.store.unloadAll('transform');
});
test('it should make request to correct endpoint when querying all records', async function (assert) {
assert.expect(2);
this.server.get(`${this.backend}/transformation`, (schema, req) => {
assert.ok(true, 'GET request made to correct endpoint when querying record');
assert.propEqual(req.queryParams, { list: 'true' }, 'query params include list: true');
return { data: { key_info: {}, keys: [] } };
});
await this.store.query('transform', { backend: this.backend });
});
test('it should make request to correct endpoint when querying a record', async function (assert) {
assert.expect(1);
this.server.get(`${this.backend}/transformation/${this.name}`, () => {
assert.ok(true, 'GET request made to correct endpoint when querying record');
return { data: { backend: this.backend, name: this.name } };
});
await this.store.queryRecord('transform', { backend: this.backend, id: this.name });
});
test('it should make request to correct endpoint when creating new record', async function (assert) {
assert.expect(3);
for (const type of TRANSFORM_TYPES) {
const name = `transform-${type}-test`;
this.server.post(`${this.backend}/transformations/${type}/${name}`, () => {
assert.ok(true, `POST request made to transformations/${type}/:name creating a record`);
return { data: { backend: this.backend, name, type } };
});
const record = this.store.createRecord('transform', { backend: this.backend, name, type });
await record.save();
}
});
test('it should make request to correct endpoint when updating record', async function (assert) {
assert.expect(3);
for (const type of TRANSFORM_TYPES) {
const name = `transform-${type}-test`;
this.server.post(`${this.backend}/transformations/${type}/${name}`, () => {
assert.ok(true, `POST request made to transformations/${type}/:name endpoint`);
});
this.store.pushPayload('transform', {
modelName: 'transform',
backend: this.backend,
id: name,
type,
name,
});
const record = this.store.peekRecord('transform', name);
await record.save();
}
});
test('it should make request to correct endpoint when deleting record', async function (assert) {
assert.expect(3);
for (const type of TRANSFORM_TYPES) {
const name = `transform-${type}-test`;
this.server.delete(`${this.backend}/transformation/${name}`, () => {
assert.ok(true, `type: ${type} - DELETE request to transformation/:name endpoint`);
});
this.store.pushPayload('transform', {
modelName: 'transform',
backend: this.backend,
id: name,
type,
name,
});
const record = this.store.peekRecord('transform', name);
await record.destroyRecord();
}
});
});