From 93c8d481fdf30d1b84b9fa7c6f215b81b4e3e2a3 Mon Sep 17 00:00:00 2001 From: Dharini Jeeva Date: Fri, 23 Feb 2024 10:07:52 -0800 Subject: [PATCH] Icu 12567 add new field credential mapping overrides (#2156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 🤖 add credential type select list --- .../generated/models/credential-library.js | 6 + addons/api/addon/models/credential-library.js | 11 ++ .../addon/serializers/credential-library.js | 26 ++++ .../mirage/factories/credential-library.js | 6 + .../serializers/credential-library-test.js | 14 ++ addons/core/translations/resources/en-us.yaml | 7 + .../vault-generic/index.hbs | 42 +++++- .../credential-library/vault-generic/index.js | 15 +- .../form/field/list-wrapper/index.hbs | 3 + .../field/list-wrapper/select-text/index.hbs | 116 +++++++++++++++ .../field/list-wrapper/select-text/index.js | 137 ++++++++++++++++++ .../credential-library/create-test.js | 14 +- .../credential-library/update-test.js | 95 +++++++----- .../form/field/list-wrapper-test.js | 61 ++++++++ 14 files changed, 517 insertions(+), 36 deletions(-) create mode 100644 ui/admin/app/components/form/field/list-wrapper/select-text/index.hbs create mode 100644 ui/admin/app/components/form/field/list-wrapper/select-text/index.js diff --git a/addons/api/addon/generated/models/credential-library.js b/addons/api/addon/generated/models/credential-library.js index 548c4202b..872ed0471 100644 --- a/addons/api/addon/generated/models/credential-library.js +++ b/addons/api/addon/generated/models/credential-library.js @@ -81,6 +81,12 @@ export default class GeneratedCredentialLibraryModel extends BaseModel { }) credential_type; + @attr('object', { + for: 'vault-generic', + description: 'It indicates the credential mapping overrides.', + }) + credential_mapping_overrides; + @attr('string', { for: 'vault-ssh-certificate', isNestedAttribute: true, diff --git a/addons/api/addon/models/credential-library.js b/addons/api/addon/models/credential-library.js index 001002e31..40f00a904 100644 --- a/addons/api/addon/models/credential-library.js +++ b/addons/api/addon/models/credential-library.js @@ -12,6 +12,17 @@ export const options = { http_method: ['GET', 'POST'], key_type: ['ed25519', 'ecdsa', 'rsa'], credential_types: ['ssh_private_key', 'username_password'], + mapping_overrides: { + username_password: { + username_attribute: 'Username', + password_attribute: 'Password', + }, + ssh_private_key: { + private_key_attribute: 'Private Key', + private_key_passphrase_attribute: 'Private Key Passphrase', + username_attribute: 'Username', + }, + }, }; export const TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC = 'vault-generic'; diff --git a/addons/api/addon/serializers/credential-library.js b/addons/api/addon/serializers/credential-library.js index 296da252e..f607d3d67 100644 --- a/addons/api/addon/serializers/credential-library.js +++ b/addons/api/addon/serializers/credential-library.js @@ -7,6 +7,7 @@ import ApplicationSerializer from './application'; import { TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, + options, } from '../models/credential-library'; export default class CredentialLibrarySerializer extends ApplicationSerializer { @@ -31,9 +32,34 @@ export default class CredentialLibrarySerializer extends ApplicationSerializer { return super.serialize(...arguments); } } + serializeAttribute(snapshot, json) { + const value = super.serializeAttribute(...arguments); + const { credential_type, type, credential_mapping_overrides, isNew } = + snapshot.record; + if ( + type === TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC && + credential_mapping_overrides && + credential_type && + !isNew + ) { + // API expects to send null to fields if it is undefined or deleted + json.credential_mapping_overrides = Object.keys( + options.mapping_overrides[credential_type], + ).reduce((obj, key) => { + if (credential_mapping_overrides[key]) { + obj[key] = credential_mapping_overrides[key]; + } else { + obj[key] = null; + } + return obj; + }, {}); + return value; + } + } serializeVaultGeneric() { const serialized = super.serialize(...arguments); + if (serialized.attributes) { // Set `http_request_body` only if `http_method` is POST // otherwise set to null to have the field removed diff --git a/addons/api/mirage/factories/credential-library.js b/addons/api/mirage/factories/credential-library.js index 7e2439d2b..de040ba63 100644 --- a/addons/api/mirage/factories/credential-library.js +++ b/addons/api/mirage/factories/credential-library.js @@ -11,12 +11,18 @@ import { TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, TYPES_CREDENTIAL_LIBRARY, + options, } from 'api/models/credential-library'; export default factory.extend({ id: () => generateId('cl_'), type: (i) => TYPES_CREDENTIAL_LIBRARY[i % TYPES_CREDENTIAL_LIBRARY.length], + credential_type(i) { + if (this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC) { + return options.credential_types[i % options.credential_types.length]; + } + }, authorized_actions: () => permissions.authorizedActionsFor('credential-library') || [ 'no-op', diff --git a/addons/api/tests/unit/serializers/credential-library-test.js b/addons/api/tests/unit/serializers/credential-library-test.js index 8cbce0bf5..0e86c4915 100644 --- a/addons/api/tests/unit/serializers/credential-library-test.js +++ b/addons/api/tests/unit/serializers/credential-library-test.js @@ -24,6 +24,7 @@ module('Unit | Serializer | credential library', function (hooks) { http_method: 'GET', version: 1, credential_type: 'ssh_private_key', + credential_mapping_overrides: { username_attribute: 'user' }, }); const snapshot = record._createSnapshot(); const serializedRecord = serializer.serialize(snapshot); @@ -34,6 +35,9 @@ module('Unit | Serializer | credential library', function (hooks) { description: 'Description', version: 1, credential_type: 'ssh_private_key', + credential_mapping_overrides: { + username_attribute: 'user', + }, attributes: { path: '/vault/path', http_method: 'GET', @@ -57,6 +61,7 @@ module('Unit | Serializer | credential library', function (hooks) { http_method: 'GET', version: 1, credential_type: 'ssh_private_key', + credential_mapping_overrides: {}, }, }, }); @@ -69,6 +74,11 @@ module('Unit | Serializer | credential library', function (hooks) { name: 'Name', description: 'Description', credential_type: 'ssh_private_key', + credential_mapping_overrides: { + private_key_attribute: null, + private_key_passphrase_attribute: null, + username_attribute: null, + }, attributes: { path: '/vault/path', http_method: 'GET', @@ -85,6 +95,7 @@ module('Unit | Serializer | credential library', function (hooks) { http_method: '', path: null, credential_type: null, + credential_mapping_overrides: {}, }); const vaultSSHCertificateRecord = store.createRecord('credential-library', { type: TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, @@ -105,6 +116,7 @@ module('Unit | Serializer | credential library', function (hooks) { name: null, type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, credential_type: null, + credential_mapping_overrides: {}, }); assert.deepEqual(serializedVaultSSHCertificateRecord, { attributes: { @@ -145,6 +157,7 @@ module('Unit | Serializer | credential library', function (hooks) { name: null, type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, credential_type: null, + credential_mapping_overrides: {}, }, 'http_request_body attribute is not expected', ); @@ -164,6 +177,7 @@ module('Unit | Serializer | credential library', function (hooks) { name: null, type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, credential_type: null, + credential_mapping_overrides: {}, }, 'http_request_body attribute is expected', ); diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 462c11378..955aba8f2 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -765,6 +765,10 @@ credential-library: new: New Credential Library ssh_private_key: SSH Private Key username_password: Username & Password + username_attribute: Username + password_attribute: Password + private_key_passphrase_attribute: Private Key Passphrase + private_key_attribute: Private Key actions: create: New Credential Library delete: Delete Credential Library @@ -779,6 +783,9 @@ credential-library: credential_type: label: Credential Type help: Specify the type of credential this library will issue. This must be set to use credential injection. After this data is saved, it cannot be changed later. + credential_mapping_overrides: + label: Credential mapping overrides + help: Specify the credential fields to use instead of the defaults http_method: label: HTTP Method help: The HTTP method the library uses when requesting credentials from Vault. diff --git a/ui/admin/app/components/form/credential-library/vault-generic/index.hbs b/ui/admin/app/components/form/credential-library/vault-generic/index.hbs index ca40cbb81..11ef18a8e 100644 --- a/ui/admin/app/components/form/credential-library/vault-generic/index.hbs +++ b/ui/admin/app/components/form/credential-library/vault-generic/index.hbs @@ -113,7 +113,7 @@ @width='100%' name='credential_type' disabled={{or form.disabled (not @model.isNew)}} - {{on 'input' (set-from-event @model 'credential_type')}} + {{on 'input' this.selectCredentialType}} as |F| > @@ -140,6 +140,46 @@ {{/if}} + {{#if @model.credential_type}} + + <:fieldset as |F|> + + {{t + 'resources.credential-library.form.credential_mapping_overrides.label' + }} + + + {{t + 'resources.credential-library.form.credential_mapping_overrides.help' + }} + + + {{#if @model.errors.credential_mapping_overrides}} + + {{#each @model.errors.credential_mapping_overrides as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + <:field as |F|> + + + + {{/if}} + + <:head as |H|> + + {{t 'form.key.label'}} + {{t 'form.value.label'}} + + {{t 'titles.actions'}} + + + + + <:body as |B|> + {{#each-in this.options as |select val|}} + + + + + {{#each-in @selectOptions as |key value|}} + + {{/each-in}} + + + + + + + + + + {{#if this.removeOptionByKey}} + + {{/if}} + + + {{/each-in}} + + {{#if this.showNewRow}} + + + + + + + {{#each-in this.selectOptions as |key value|}} + + {{/each-in}} + + + + + + + + + + + + + {{/if}} + + +{{/let}} \ No newline at end of file diff --git a/ui/admin/app/components/form/field/list-wrapper/select-text/index.js b/ui/admin/app/components/form/field/list-wrapper/select-text/index.js new file mode 100644 index 000000000..9a8fba662 --- /dev/null +++ b/ui/admin/app/components/form/field/list-wrapper/select-text/index.js @@ -0,0 +1,137 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { set } from '@ember/object'; + +export default class MappingListComponent extends Component { + // =attributes + + /** + * @type {string} + */ + @tracked newOptionKey = ''; + + /** + * @type {string} + */ + @tracked newOptionValue = ''; + + /** + * Returns an object of key/value pair that the user enters + * @type {object} + */ + get options() { + return this.args?.options || this.args?.model?.[this.args.name]; + } + + /** + * Determines if we need to show an empty row to the users to enter more key/value pairs based on removeDuplicates arg, + * by default it is true + * @type {object} + */ + get showNewRow() { + if (this.args.removeDuplicates) { + return ( + Object.keys(this.options || {}).length !== + Object.keys(this.args.selectOptions || {}).length + ); + } else { + return true; + } + } + + /** + * Prevents users from selecting duplicate keys from the select list if there's the arg is set to true + * @type {object} + */ + get selectOptions() { + const previouslySelectedKeys = Object.keys(this.options || {}); + if (this.args.removeDuplicates && previouslySelectedKeys.length) { + const newObj = { ...this.args.selectOptions }; + for (const key of Object.keys(newObj)) { + if (previouslySelectedKeys.includes(key)) { + delete newObj[key]; + } + } + return newObj; + } else { + return this.args.selectOptions; + } + } + // =actions + + /** + * Handles when then user makes changes to the select list + * @param oldkey {string} + * @param oldVal {string} + */ + @action + selectChange(oldkey, oldVal, { target: { value: newKey } }) { + const field = this.args.name; + const newObj = { ...this.args.model[field], [newKey]: oldVal }; + delete newObj[oldkey]; + set(this.args.model, field, newObj); + } + + /** + * Handles input changes + * @param key {string} + */ + @action + updateInput(key, { target: { value: newValue } }) { + const field = this.args.name; + this.args.model[field] = { + ...this.args.model[field], + [key]: newValue, + }; + } + + /** + * If a new key value is entered and an addOption method was specified, + * calls addOption with the new key and value. Resets key and value. + * Otherwise use the model argument to create the array and update the model + */ + + @action + addOption() { + if (this.args.addOption) { + if (this.newOptionKey) { + this.args.addOption({ + key: this.newOptionKey, + value: this.newOptionValue, + }); + } + } else { + const field = this.args.name; + this.args.model[field] = { + ...this.args.model[field], + [this.newOptionKey]: this.newOptionValue, + }; + } + + this.newOptionKey = ''; + this.newOptionValue = ''; + } + + /** + * If removeOptionByIndex method was passed, use that. + * Otherwise, use the model to remove an option by key + * @param index {number} + */ + @action + removeOptionByKey(selectedKey) { + if (this.args.removeOptionByKey) { + this.args.removeOptionByKey(selectedKey); + } else { + const field = this.args.name; + const newObj = { ...this.args.model[field] }; + delete newObj[selectedKey]; + set(this.args.model, field, newObj); + } + } +} diff --git a/ui/admin/tests/acceptance/credential-library/create-test.js b/ui/admin/tests/acceptance/credential-library/create-test.js index d979fdd35..a8219dcf8 100644 --- a/ui/admin/tests/acceptance/credential-library/create-test.js +++ b/ui/admin/tests/acceptance/credential-library/create-test.js @@ -95,15 +95,27 @@ module('Acceptance | credential-libraries | create', function (hooks) { await fillIn('[name="name"]', 'random string'); await select('[name="credential_type"]', 'ssh_private_key'); + await select( + '[name="credential_mapping_overrides"] tbody td:nth-of-type(1) select', + 'private_key_attribute', + ); + await fillIn( + '[name="credential_mapping_overrides"] tbody td:nth-of-type(2) input', + 'key', + ); + await click('[name="credential_mapping_overrides"] button'); + await click('[type="submit"]'); assert.strictEqual(getCredentialLibraryCount(), count + 1); const credentialLibrary = this.server.schema.credentialLibraries.findBy({ name: 'random string', }); - assert.strictEqual(credentialLibrary.name, 'random string'); assert.strictEqual(credentialLibrary.credentialType, 'ssh_private_key'); + assert.deepEqual(credentialLibrary.credentialMappingOverrides, { + private_key_attribute: 'key', + }); }); test('can create a new credential library of type vault ssh cert', async function (assert) { diff --git a/ui/admin/tests/acceptance/credential-library/update-test.js b/ui/admin/tests/acceptance/credential-library/update-test.js index 24156f3cb..54ef95b86 100644 --- a/ui/admin/tests/acceptance/credential-library/update-test.js +++ b/ui/admin/tests/acceptance/credential-library/update-test.js @@ -22,6 +22,17 @@ module('Acceptance | credential-libraries | update', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); + const BUTTON_SELECTOR = '.rose-form-actions [type="button"]'; + const SAVE_BTN_SELECTOR = '.rose-form-actions [type="submit"]'; + const NAME_INPUT_SELECTOR = '[name="name"]'; + const FIELD_ERROR_TEXT_SELECTOR = '.hds-form-error__message'; + const DESC_INPUT_SELECTOR = '[name="description"]'; + const VAULT_PATH_SELECTOR = '[name="vault_path"]'; + const CRED_TYPE_SELECTOR = '[name="credential_type"]'; + const CRED_MAPPING_OVERRIDES_SELECT = + '[name="credential_mapping_overrides"] tbody td:nth-of-type(1) select'; + const CRED_MAPPING_OVERRIDES_INPUT = + '[name="credential_mapping_overrides"] tbody td:nth-of-type(2) input'; const instances = { scopes: { global: null, @@ -73,43 +84,60 @@ module('Acceptance | credential-libraries | update', function (hooks) { authenticateSession({}); }); - test('can update resource and save changes', async function (assert) { - assert.notEqual(instances.credentialLibrary.name, 'random string'); - await visit(urls.credentialLibrary); - await click('form [type="button"]', 'Activate edit mode'); - await fillIn('[name="name"]', 'random string'); - await click('.rose-form-actions [type="submit"]'); - assert.strictEqual(currentURL(), urls.credentialLibrary); - assert.strictEqual( - this.server.schema.credentialLibraries.all().models[0].name, - 'random string', - ); - }); - test('cannot update resource without proper authorization', async function (assert) { instances.credentialLibrary.authorized_actions = instances.credentialLibrary.authorized_actions.filter( (item) => item !== 'update', ); await visit(urls.credentialLibrary); - assert.notOk(find('form [type="button"]')); + assert.notOk(find(BUTTON_SELECTOR)); }); test('can update a credential library and cancel changes', async function (assert) { await visit(urls.credentialLibrary); - await click('form [type="button"]', 'Activate edit mode'); - await fillIn('[name="name"]', 'random string'); - await click('.rose-form-actions [type="button"]'); + await click(BUTTON_SELECTOR, 'Activate edit mode'); + await fillIn(NAME_INPUT_SELECTOR, 'random string'); + await click(BUTTON_SELECTOR); assert.notEqual( this.server.schema.credentialLibraries.all().models[0].name, 'random string', ); assert.strictEqual( - find('[name="name"]').value, + find(NAME_INPUT_SELECTOR).value, instances.credentialLibrary.name, ); }); + test('can update a vault generic credential library and save changes', async function (assert) { + await visit( + `${urls.credentialLibraries}/${instances.credentialLibrary.id}`, + ); + + await click(BUTTON_SELECTOR); + + await fillIn(NAME_INPUT_SELECTOR, 'random string'); + await fillIn(DESC_INPUT_SELECTOR, 'description'); + await fillIn(VAULT_PATH_SELECTOR, 'path'); + + await select(CRED_MAPPING_OVERRIDES_SELECT, 'private_key_attribute'); + await fillIn(CRED_MAPPING_OVERRIDES_INPUT, 'key'); + + await click('[name="credential_mapping_overrides"] button'); + + await click(SAVE_BTN_SELECTOR); + const credentialLibrary = this.server.schema.credentialLibraries.findBy({ + name: 'random string', + }); + assert.strictEqual(credentialLibrary.name, 'random string'); + assert.strictEqual(credentialLibrary.description, 'description'); + assert.strictEqual(credentialLibrary.attributes.path, 'path'); + assert.deepEqual(credentialLibrary.credentialMappingOverrides, { + private_key_attribute: 'key', + private_key_passphrase_attribute: null, + username_attribute: null, + }); + }); + test('saving an existing credential library with invalid fields displays error messages', async function (assert) { this.server.patch('/credential-libraries/:id', () => { return new Response( @@ -131,15 +159,15 @@ module('Acceptance | credential-libraries | update', function (hooks) { ); }); await visit(urls.credentialLibrary); - await click('form [type="button"]', 'Activate edit mode'); - await fillIn('[name="name"]', 'random string'); - await click('[type="submit"]'); + await click(BUTTON_SELECTOR, 'Activate edit mode'); + await fillIn(NAME_INPUT_SELECTOR, 'random string'); + await click(SAVE_BTN_SELECTOR); assert.ok( find('[role="alert"]').textContent.trim(), 'The request was invalid.', ); assert.ok( - find('.hds-form-error__message').textContent.trim(), + find(FIELD_ERROR_TEXT_SELECTOR).textContent.trim(), 'Name is required.', ); }); @@ -151,8 +179,8 @@ module('Acceptance | credential-libraries | update', function (hooks) { assert.notEqual(instances.credentialLibrary.name, 'random string'); await visit(urls.credentialLibrary); - await click('form [type="button"]', 'Activate edit mode'); - await fillIn('[name="name"]', 'random string'); + await click(BUTTON_SELECTOR, 'Activate edit mode'); + await fillIn(NAME_INPUT_SELECTOR, 'random string'); assert.strictEqual(currentURL(), urls.credentialLibrary); try { @@ -174,8 +202,8 @@ module('Acceptance | credential-libraries | update', function (hooks) { confirmService.enabled = true; assert.notEqual(instances.credentialLibrary.name, 'random string'); await visit(urls.credentialLibrary); - await click('form [type="button"]', 'Activate edit mode'); - await fillIn('[name="name"]', 'random string'); + await click(BUTTON_SELECTOR, 'Activate edit mode'); + await fillIn(NAME_INPUT_SELECTOR, 'random string'); assert.strictEqual(currentURL(), urls.credentialLibrary); try { @@ -193,8 +221,8 @@ module('Acceptance | credential-libraries | update', function (hooks) { test('cannot update credential type in a vault generic credential library', async function (assert) { await visit(urls.credentialLibrary); - await click('form [type="button"]', 'Activate edit mode'); - assert.dom('[name=credential_type]').isDisabled(); + await click(BUTTON_SELECTOR, 'Activate edit mode'); + assert.dom(CRED_TYPE_SELECTOR).isDisabled(); }); test('can update a vault ssh cert credential library and save changes', async function (assert) { @@ -206,10 +234,10 @@ module('Acceptance | credential-libraries | update', function (hooks) { await visit( `${urls.credentialLibraries}/${instances.credentialLibrary.id}`, ); - await click('form [type="button"]:not(:disabled)', 'Activate edit mode'); - await fillIn('[name="name"]', 'name'); - await fillIn('[name="description"]', 'description'); - await fillIn('[name="vault_path"]', 'path'); + await click(BUTTON_SELECTOR, 'Activate edit mode'); + await fillIn(NAME_INPUT_SELECTOR, 'name'); + await fillIn(DESC_INPUT_SELECTOR, 'description'); + await fillIn(VAULT_PATH_SELECTOR, 'path'); await fillIn('[name="username"]', 'username'); await select('[name="key_type"]', 'rsa'); await fillIn('[name="key_bits"]', 100); @@ -234,7 +262,8 @@ module('Acceptance | credential-libraries | update', function (hooks) { ); await click('[name="extensions"] button'); - await click('[type="submit"]'); + + await click(SAVE_BTN_SELECTOR); const credentialLibrary = this.server.schema.credentialLibraries.findBy({ type: TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, }); diff --git a/ui/admin/tests/integration/components/form/field/list-wrapper-test.js b/ui/admin/tests/integration/components/form/field/list-wrapper-test.js index 904cc172f..01a25c597 100644 --- a/ui/admin/tests/integration/components/form/field/list-wrapper-test.js +++ b/ui/admin/tests/integration/components/form/field/list-wrapper-test.js @@ -163,4 +163,65 @@ module('Integration | Component | list-wrapper', function (hooks) { await click('.list-wrapper-field [data-test-remove-option-button=option1]'); assert.dom('tbody tr').exists({ count: 3 }); }); + + test('it renders multiple options with select and text input as key value pair', async function (assert) { + this.options = { + username_attribute: 'user', + password_attribute: 'pass', + }; + + this.selectOptions = { + username_attribute: 'User Key', + password_attribute: 'Pass Key', + }; + await render(hbs` + + <:field as |F|> + + + + `); + + assert.dom('tbody tr').exists({ count: 2 }); + assert + .dom('.list-wrapper-field tbody tr:last-child input') + .hasValue('pass'); + assert + .dom('.list-wrapper-field tbody tr:last-child select') + .hasValue('password_attribute'); + }); + + test('it does not render new rows when the select option limit is reached by passing in @removeDuplicates', async function (assert) { + this.options = { username_attribute: 'user', password_attribute: 'pass' }; + this.selectOptions = { + username_attribute: 'User Key', + password_attribute: 'Pass Key', + }; + await render(hbs` + + <:field as |F|> + + + + `); + + assert.dom('tbody tr').exists({ count: 2 }); + }); + + test('it does render unlimited new rows when @removeDuplicates is not passed', async function (assert) { + this.options = { username_attribute: 'user', password_attribute: 'pass' }; + this.selectOptions = { + username_attribute: 'User Key', + password_attribute: 'Pass Key', + }; + await render(hbs` + + <:field as |F|> + + + + `); + + assert.dom('tbody tr').exists({ count: 3 }); + }); });