From b14c6ea83fb4bd2ab002e89ad688919f34b13f69 Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Tue, 9 Jun 2026 08:02:49 -0600 Subject: [PATCH] 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 --- ui/app/components/alphabet-edit.hbs | 10 +- ui/app/components/alphabet-edit.js | 20 +- ui/app/components/transform-create-form.hbs | 25 --- ui/app/components/transform-create-form.js | 8 - ui/app/components/transform-edit-base.js | 136 ------------- ui/app/components/transform-edit-form.hbs | 56 ------ ui/app/components/transform-edit-form.js | 8 - .../transform-show-transformation.hbs | 74 ------- .../transform-show-transformation.js | 34 ---- ui/app/components/transform-template-edit.js | 11 +- ui/app/components/transformation-edit.js | 12 +- ui/app/models/transform.js | 140 ------------- .../components/alphabet-edit-test.ts | 106 ++++++++++ .../components/transform-edit-base-test.js | 31 --- .../components/transformation-edit-test.ts | 186 ++++++++++++++++++ ui/tests/unit/adapters/transform-test.js | 95 --------- 16 files changed, 331 insertions(+), 621 deletions(-) delete mode 100644 ui/app/components/transform-create-form.hbs delete mode 100644 ui/app/components/transform-create-form.js delete mode 100644 ui/app/components/transform-edit-base.js delete mode 100644 ui/app/components/transform-edit-form.hbs delete mode 100644 ui/app/components/transform-edit-form.js delete mode 100644 ui/app/components/transform-show-transformation.hbs delete mode 100644 ui/app/components/transform-show-transformation.js delete mode 100644 ui/app/models/transform.js create mode 100644 ui/tests/integration/components/alphabet-edit-test.ts delete mode 100644 ui/tests/integration/components/transform-edit-base-test.js create mode 100644 ui/tests/integration/components/transformation-edit-test.ts delete mode 100644 ui/tests/unit/adapters/transform-test.js diff --git a/ui/app/components/alphabet-edit.hbs b/ui/app/components/alphabet-edit.hbs index 435e927f38..2603e968d0 100644 --- a/ui/app/components/alphabet-edit.hbs +++ b/ui/app/components/alphabet-edit.hbs @@ -2,7 +2,7 @@ Copyright IBM Corp. 2016, 2025 SPDX-License-Identifier: BUSL-1.1 }} - + <:breadcrumbs> @@ -12,7 +12,13 @@ {{#if @capabilities.canDelete}} - +
{{/if}} {{#if @capabilities.canUpdate}} diff --git a/ui/app/components/alphabet-edit.js b/ui/app/components/alphabet-edit.js index 95fea0ffe6..1c50794e78 100644 --- a/ui/app/components/alphabet-edit.js +++ b/ui/app/components/alphabet-edit.js @@ -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; diff --git a/ui/app/components/transform-create-form.hbs b/ui/app/components/transform-create-form.hbs deleted file mode 100644 index b9de8b90e0..0000000000 --- a/ui/app/components/transform-create-form.hbs +++ /dev/null @@ -1,25 +0,0 @@ -{{! - Copyright IBM Corp. 2016, 2025 - SPDX-License-Identifier: BUSL-1.1 -}} - -
-
- - - {{#each this.model.transformFieldAttrs as |attr|}} - - {{/each}} -
-
- - - - -
-
\ No newline at end of file diff --git a/ui/app/components/transform-create-form.js b/ui/app/components/transform-create-form.js deleted file mode 100644 index 480f2f8b1e..0000000000 --- a/ui/app/components/transform-create-form.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2025 - * SPDX-License-Identifier: BUSL-1.1 - */ - -import TransformationEdit from './transformation-edit'; - -export default TransformationEdit.extend({}); diff --git a/ui/app/components/transform-edit-base.js b/ui/app/components/transform-edit-base.js deleted file mode 100644 index 5dd3154545..0000000000 --- a/ui/app/components/transform-edit-base.js +++ /dev/null @@ -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(); - }, - }, -}); diff --git a/ui/app/components/transform-edit-form.hbs b/ui/app/components/transform-edit-form.hbs deleted file mode 100644 index 22a81ddd98..0000000000 --- a/ui/app/components/transform-edit-form.hbs +++ /dev/null @@ -1,56 +0,0 @@ -{{! - Copyright IBM Corp. 2016, 2025 - SPDX-License-Identifier: BUSL-1.1 -}} - -
-
- - - {{#each this.model.transformFieldAttrs as |attr|}} - {{#if (or (eq attr.name "name") (eq attr.name "type"))}} - - {{#if attr.options.subText}} -

{{attr.options.subText}}

- {{/if}} - {{#if attr.options.possibleValues}} -
-
- -
-
- {{else}} - - {{/if}} - {{else}} - - {{/if}} - {{/each}} -
-
- - - - -
-
\ No newline at end of file diff --git a/ui/app/components/transform-edit-form.js b/ui/app/components/transform-edit-form.js deleted file mode 100644 index aecf0316fb..0000000000 --- a/ui/app/components/transform-edit-form.js +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2025 - * SPDX-License-Identifier: BUSL-1.1 - */ - -import TransformationEdit from './transformation-edit'; - -export default TransformationEdit.extend(); diff --git a/ui/app/components/transform-show-transformation.hbs b/ui/app/components/transform-show-transformation.hbs deleted file mode 100644 index 9fd1cda1a5..0000000000 --- a/ui/app/components/transform-show-transformation.hbs +++ /dev/null @@ -1,74 +0,0 @@ -{{! - Copyright IBM Corp. 2016, 2025 - SPDX-License-Identifier: BUSL-1.1 -}} - -
- {{#each this.model.transformFieldAttrs as |attr|}} - {{#if (eq attr.type "object")}} - - {{else if (eq attr.type "array")}} - - {{else}} - - {{/if}} - {{/each}} -
- -
- -
-

Encode

-
- - To test the encoding capability of your transformation, use the following command. It will output an encoded_value. - -
-
- {{#let (concat "vault write " this.model.backend "/encode/" this.cliCommand) as |copyEncodeCommand|}} - - {{/let}} -
-
-
-

Decode

-
- - To test decoding capability of your transformation, use the encoded_value in the following command. It should return - your original input. - -
-
- {{#let (concat "vault write " this.model.backend "/decode/" this.cliCommand) as |copyDecodeCommand|}} - - {{/let}} -
-
-
\ No newline at end of file diff --git a/ui/app/components/transform-show-transformation.js b/ui/app/components/transform-show-transformation.js deleted file mode 100644 index d5206fc591..0000000000 --- a/ui/app/components/transform-show-transformation.js +++ /dev/null @@ -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 = ''; - const value = 'value='; - 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='; - } - - return `${role} ${value} ${tweak} transformation=${name}`; - }), -}); diff --git a/ui/app/components/transform-template-edit.js b/ui/app/components/transform-template-edit.js index 57bc1fa98c..7e0f92a5c7 100644 --- a/ui/app/components/transform-template-edit.js +++ b/ui/app/components/transform-template-edit.js @@ -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'; } diff --git a/ui/app/components/transformation-edit.js b/ui/app/components/transformation-edit.js index 44fd2da670..684bdd2882 100644 --- a/ui/app/components/transformation-edit.js +++ b/ui/app/components/transformation-edit.js @@ -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() { diff --git a/ui/app/models/transform.js b/ui/app/models/transform.js deleted file mode 100644 index cc7e3e276b..0000000000 --- a/ui/app/models/transform.js +++ /dev/null @@ -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 you’d 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'), -}); diff --git a/ui/tests/integration/components/alphabet-edit-test.ts b/ui/tests/integration/components/alphabet-edit-test.ts new file mode 100644 index 0000000000..40d3e6c103 --- /dev/null +++ b/ui/tests/integration/components/alphabet-edit-test.ts @@ -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; + 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`` + ); + + 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`` + ); + + 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`` + ); + + 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`` + ); + + await click('[data-test-delete]'); + + assert.ok( + deleteStub.calledWith('my-alphabet', 'transform'), + 'calls transformDeleteAlphabet with correct args' + ); + deleteStub.restore(); + }); +}); diff --git a/ui/tests/integration/components/transform-edit-base-test.js b/ui/tests/integration/components/transform-edit-base-test.js deleted file mode 100644 index 2e8c886105..0000000000 --- a/ui/tests/integration/components/transform-edit-base-test.js +++ /dev/null @@ -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``); - - assert.dom(this.element).hasText(''); - - // Template block usage: - await render(hbs` - - template block text - - `); - - assert.dom(this.element).hasText('template block text'); - }); -}); diff --git a/ui/tests/integration/components/transformation-edit-test.ts b/ui/tests/integration/components/transformation-edit-test.ts new file mode 100644 index 0000000000..bca3c614a3 --- /dev/null +++ b/ui/tests/integration/components/transformation-edit-test.ts @@ -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; + 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`` + ); + + 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`` + ); + + 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`` + ); + + // 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`` + ); + + // 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`` + ); + + 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`` + ); + + 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`` + ); + + 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'); + }); +}); diff --git a/ui/tests/unit/adapters/transform-test.js b/ui/tests/unit/adapters/transform-test.js deleted file mode 100644 index 1e536d4737..0000000000 --- a/ui/tests/unit/adapters/transform-test.js +++ /dev/null @@ -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(); - } - }); -});