diff --git a/addons/api/addon/generated/models/credential.js b/addons/api/addon/generated/models/credential.js index dc765141e..9e271a443 100644 --- a/addons/api/addon/generated/models/credential.js +++ b/addons/api/addon/generated/models/credential.js @@ -51,17 +51,17 @@ export default class GeneratedCredentialModel extends BaseModel { }) version; - // =attributes (username_password, ssh_private_key) + // =attributes (username_password, ssh_private_key, username_password_domain) @attr('string', { - for: ['username_password', 'ssh_private_key'], + for: ['username_password', 'ssh_private_key', 'username_password_domain'], isNestedAttribute: true, description: 'The username for credential.', }) username; - // =attributes (username_password) + // =attributes (username_password, username_password_domain) @attr('string', { - for: 'username_password', + for: ['username_password', 'username_password_domain'], isNestedAttribute: true, isSecret: true, description: 'The password for credential.', @@ -92,4 +92,12 @@ export default class GeneratedCredentialModel extends BaseModel { description: 'The secret associated with credential.', }) json_object; + + // =attributes (username_password_domain) + @attr('string', { + for: 'username_password_domain', + isNestedAttribute: true, + description: 'The domain for credential.', + }) + domain; } diff --git a/addons/api/addon/models/credential-library.js b/addons/api/addon/models/credential-library.js index 1ca9a7728..7f7a6c9c3 100644 --- a/addons/api/addon/models/credential-library.js +++ b/addons/api/addon/models/credential-library.js @@ -4,14 +4,22 @@ */ import GeneratedCredentialLibraryModel from '../generated/models/credential-library'; - +import { + TYPE_CREDENTIAL_SSH_PRIVATE_KEY, + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + TYPE_CREDENTIAL_USERNAME_PASSWORD, +} from 'api/models/credential'; /** * Enum options for credential library. */ export const options = { http_method: ['GET', 'POST'], key_type: ['ed25519', 'ecdsa', 'rsa'], - credential_types: ['ssh_private_key', 'username_password'], + credential_types: [ + TYPE_CREDENTIAL_SSH_PRIVATE_KEY, + TYPE_CREDENTIAL_USERNAME_PASSWORD, + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + ], mapping_overrides: { username_password: ['username_attribute', 'password_attribute'], ssh_private_key: [ @@ -19,6 +27,11 @@ export const options = { 'private_key_passphrase_attribute', 'username_attribute', ], + username_password_domain: [ + 'username_attribute', + 'password_attribute', + 'domain_attribute', + ], }, }; diff --git a/addons/api/addon/models/credential.js b/addons/api/addon/models/credential.js index e4e4bd21b..146dcbf75 100644 --- a/addons/api/addon/models/credential.js +++ b/addons/api/addon/models/credential.js @@ -12,10 +12,12 @@ import GeneratedCredentialModel from '../generated/models/credential'; export const TYPE_CREDENTIAL_USERNAME_PASSWORD = 'username_password'; export const TYPE_CREDENTIAL_SSH_PRIVATE_KEY = 'ssh_private_key'; export const TYPE_CREDENTIAL_JSON = 'json'; +export const TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN = 'username_password_domain'; export const TYPES_CREDENTIAL = Object.freeze([ TYPE_CREDENTIAL_USERNAME_PASSWORD, TYPE_CREDENTIAL_SSH_PRIVATE_KEY, + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, TYPE_CREDENTIAL_JSON, ]); diff --git a/addons/api/mirage/factories/credential.js b/addons/api/mirage/factories/credential.js index b408fefa6..c7349fb97 100644 --- a/addons/api/mirage/factories/credential.js +++ b/addons/api/mirage/factories/credential.js @@ -7,8 +7,14 @@ import factory from '../generated/factories/credential'; import { faker } from '@faker-js/faker'; import permissions from '../helpers/permissions'; import generatedId from '../helpers/id'; +import { + TYPES_CREDENTIAL, + TYPE_CREDENTIAL_SSH_PRIVATE_KEY, + TYPE_CREDENTIAL_USERNAME_PASSWORD, + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, +} from 'api/models/credential'; -const types = ['username_password', 'ssh_private_key', 'json']; +const types = [...TYPES_CREDENTIAL]; export default factory.extend({ type: (i) => types[i % types.length], @@ -20,6 +26,8 @@ export default factory.extend({ return generatedId('credup_'); case 'json': return generatedId('credjson_'); + case 'username_password_domain': + return generatedId('credupd_'); } }, authorized_actions: () => @@ -30,8 +38,21 @@ export default factory.extend({ 'delete', ], attributes() { - return { - username: faker.internet.userName(), - }; + switch (this.type) { + case TYPE_CREDENTIAL_USERNAME_PASSWORD: + return { username: faker.internet.userName() }; + + case TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN: + return { + username: faker.internet.userName(), + domain: faker.internet.domainName(), + }; + + case TYPE_CREDENTIAL_SSH_PRIVATE_KEY: + return { username: faker.internet.userName() }; + + default: + return {}; + } }, }); diff --git a/addons/api/tests/unit/models/credential-test.js b/addons/api/tests/unit/models/credential-test.js index 519b76352..4491e22aa 100644 --- a/addons/api/tests/unit/models/credential-test.js +++ b/addons/api/tests/unit/models/credential-test.js @@ -5,6 +5,13 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; +import { + TYPE_CREDENTIAL_JSON, + TYPE_CREDENTIAL_SSH_PRIVATE_KEY, + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + TYPE_CREDENTIAL_USERNAME_PASSWORD +} from 'api/models/credential'; + module('Unit | Model | credential', function (hooks) { setupTest(hooks); @@ -28,12 +35,12 @@ module('Unit | Model | credential', function (hooks) { test('it has isJSON property and returns the expected values', async function (assert) { const store = this.owner.lookup('service:store'); - const modelA = store.createRecord('credential', { type: 'json' }); + const modelA = store.createRecord('credential', { type: TYPE_CREDENTIAL_JSON }); const modelB = store.createRecord('credential', { - type: 'username_password', + type: TYPE_CREDENTIAL_USERNAME_PASSWORD, }); const modelC = store.createRecord('credential', { - type: 'ssh_private_key', + type: TYPE_CREDENTIAL_SSH_PRIVATE_KEY, }); assert.true(modelA.isJSON); @@ -44,15 +51,17 @@ module('Unit | Model | credential', function (hooks) { test('it has isUnknown property and returns the expected values', async function (assert) { const store = this.owner.lookup('service:store'); const modelA = store.createRecord('credential', { - type: 'username_password', + type: TYPE_CREDENTIAL_USERNAME_PASSWORD }); const modelB = store.createRecord('credential', { - type: 'ssh_private_key', + type: TYPE_CREDENTIAL_SSH_PRIVATE_KEY }); const modelC = store.createRecord('credential', { type: 'unknown' }); + const modelD = store.createRecord('credential', { type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN }); assert.false(modelA.isUnknown); assert.false(modelB.isUnknown); assert.true(modelC.isUnknown); + assert.false(modelD.isUnknown); }); }); diff --git a/addons/core/translations/resources/en-us.yaml b/addons/core/translations/resources/en-us.yaml index 554d81b9c..ddb5f76be 100644 --- a/addons/core/translations/resources/en-us.yaml +++ b/addons/core/translations/resources/en-us.yaml @@ -904,10 +904,12 @@ credential-library: new: New Credential Library ssh_private_key: SSH Private Key username_password: Username & Password + username_password_domain: Username, Password & Domain username_attribute: Username password_attribute: Password private_key_passphrase_attribute: Private Key Passphrase private_key_attribute: Private Key + domain_attribute: Domain actions: create: New Credential Library delete: Delete Credential Library @@ -922,6 +924,7 @@ 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. + description: The type of credential the library uses for credential injection. credential_mapping_overrides: label: Credential mapping overrides help: Specify the credential fields to use instead of the defaults @@ -974,12 +977,14 @@ credential: types: username_password: Username & Password ssh_private_key: Username & Key Pair + username_password_domain: Username, Password & Domain json: JSON unknown: Unknown help: username_password: Connect using the provided username and password. ssh_private_key: Connect using a username, public key, and private key. json: Connect using a JSON blob containing the credentials. + username_password_domain: Includes a domain field for Active Directory form: username: label: Username @@ -987,6 +992,9 @@ credential: password: label: Password help: The password to use when signing in with this credential store. + domain: + label: Domain + help: The Active Directory domain or FQDN (e.g., contoso.local) private_key: label: SSH Private Key help: The private key to use when connecting with this credential store. diff --git a/e2e-tests/admin/pages/credential-stores.js b/e2e-tests/admin/pages/credential-stores.js index 38d6673b3..e0f77cca4 100644 --- a/e2e-tests/admin/pages/credential-stores.js +++ b/e2e-tests/admin/pages/credential-stores.js @@ -224,6 +224,71 @@ export class CredentialStoresPage extends BaseResourcePage { return credentialName; } + /** + * Creates a static username, password & Domain credential. + * Assumes you have selected the desired project. + * @param {string} username username for the credential + * @param {string} password password for the credential + * @param {string} domain domain for the credential + * @returns Name of the credential + */ + async createStaticCredentialUsernamePasswordDomain( + username, + password, + domain, + ) { + const credentialsBreadcrumbIsVisible = await this.page + .getByRole('breadcrumbs', { name: 'Credentials' }) + .isVisible(); + if (credentialsBreadcrumbIsVisible) { + await this.page.getByRole('breadcrumbs', { name: 'Credentials' }).click(); + } else { + await this.page + .getByRole('link', { name: 'Credentials', exact: true }) + .click(); + } + + await expect( + this.page + .getByRole('navigation', { name: 'breadcrumbs' }) + .getByText('Credentials'), + ).toBeVisible(); + + const newButtonIsVisible = await this.page + .getByRole('link', { name: 'New', exact: true }) + .isVisible(); + if (newButtonIsVisible) { + await this.page.getByRole('link', { name: 'New', exact: true }).click(); + } else { + await this.page.getByText('Manage').click(); + await this.page.getByRole('link', { name: 'New Credential' }).click(); + } + + const credentialName = 'Credential ' + nanoid(); + await this.page.getByLabel('Name (Optional)').fill(credentialName); + await this.page.getByLabel('Description').fill('This is an automated test'); + await this.page + .getByRole('group', { name: 'Type' }) + .getByLabel('Username, Password & Domain') + .click(); + await this.page + .getByLabel('Username Required', { exact: true }) + .fill(username); + await this.page + .getByLabel('Password Required', { exact: true }) + .fill(password); + await this.page.getByLabel('Domain Required', { exact: true }).fill(domain); + await this.page.getByRole('button', { name: 'Save' }).click(); + await this.dismissSuccessAlert(); + await expect( + this.page + .getByRole('navigation', { name: 'breadcrumbs' }) + .getByText(credentialName), + ).toBeVisible(); + + return credentialName; + } + /** * Creates a vault-generic credential library. Assumes you have selected * the desired credential store. diff --git a/e2e-tests/admin/tests/credential-store-static-ent.spec.js b/e2e-tests/admin/tests/credential-store-static-ent.spec.js index b022a212c..2ff825cb5 100644 --- a/e2e-tests/admin/tests/credential-store-static-ent.spec.js +++ b/e2e-tests/admin/tests/credential-store-static-ent.spec.js @@ -46,7 +46,9 @@ test( targetPort, ); const credentialStoresPage = new CredentialStoresPage(page); - await credentialStoresPage.createStaticCredentialStore(); + const credentialStoreName = + await credentialStoresPage.createStaticCredentialStore(); + const credentialName = await credentialStoresPage.createStaticCredentialKeyPair( sshUser, @@ -58,6 +60,32 @@ test( 'testPassword', ); + const credentialName3 = + await credentialStoresPage.createStaticCredentialUsernamePasswordDomain( + sshUser, + 'testPassword', + 'testDomain', + ); + + // TODO: we can test the target association after RDP target is implemented, for now we test if the credential is visible in the UI + await page + .getByRole('navigation', { name: 'Application local navigation' }) + .getByRole('link', { name: 'Credential Stores' }) + .click(); + await expect( + page.getByRole('link', { name: credentialStoreName }), + ).toBeVisible(); + + // Navigate to the Credential Store + await page.getByRole('link', { name: credentialStoreName }).click(); + + // click on the Credentials tab + await page.getByRole('link', { name: 'Credentials' }).click(); + + await expect( + page.getByRole('link').getByText(credentialName3), + ).toBeVisible(); + await targetsPage.addBrokeredCredentialsToTarget( targetName, credentialName, diff --git a/e2e-tests/admin/tests/credential-store-static.spec.js b/e2e-tests/admin/tests/credential-store-static.spec.js index e9acbb69a..65203022d 100644 --- a/e2e-tests/admin/tests/credential-store-static.spec.js +++ b/e2e-tests/admin/tests/credential-store-static.spec.js @@ -22,6 +22,7 @@ test.beforeAll(async () => { let orgName; let projectName; let targetName; +let credentialStoreName; test.beforeEach(async ({ page, targetAddress, targetPort }) => { await page.goto('/'); @@ -36,7 +37,8 @@ test.beforeEach(async ({ page, targetAddress, targetPort }) => { targetPort, ); const credentialStoresPage = new CredentialStoresPage(page); - await credentialStoresPage.createStaticCredentialStore(); + credentialStoreName = + await credentialStoresPage.createStaticCredentialStore(); }); test( @@ -280,6 +282,7 @@ test( }) => { try { const credentialStoresPage = new CredentialStoresPage(page); + const credentialName = await credentialStoresPage.createStaticCredentialKeyPair( sshUser, @@ -291,6 +294,33 @@ test( 'testPassword', ); + const credentialName3 = + await credentialStoresPage.createStaticCredentialUsernamePasswordDomain( + sshUser, + 'testPassword', + 'testDomain', + ); + + // TODO: Handle target association later when we work on RDP targets and modify the existing target association + // for now we simply test if the credential is visible in the UI + await page + .getByRole('navigation', { name: 'Application local navigation' }) + .getByRole('link', { name: 'Credential Stores' }) + .click(); + await expect( + page.getByRole('link', { name: credentialStoreName }), + ).toBeVisible(); + + // Navigate to the Credential Store + await page.getByRole('link', { name: credentialStoreName }).click(); + + // click on the Credentials tab + await page.getByRole('link', { name: 'Credentials' }).click(); + + await expect( + page.getByRole('link').getByText(credentialName3), + ).toBeVisible(); + const targetsPage = new TargetsPage(page); await targetsPage.addBrokeredCredentialsToTarget( targetName, diff --git a/e2e-tests/admin/tests/credential-store-vault-ent.spec.js b/e2e-tests/admin/tests/credential-store-vault-ent.spec.js index 3c0325b1a..29efa33cb 100644 --- a/e2e-tests/admin/tests/credential-store-vault-ent.spec.js +++ b/e2e-tests/admin/tests/credential-store-vault-ent.spec.js @@ -3,6 +3,7 @@ * SPDX-License-Identifier: BUSL-1.1 */ +import { expect } from '@playwright/test'; import { test } from '../../global-setup.js'; import { execSync } from 'child_process'; @@ -53,31 +54,14 @@ test( let orgId; let connect; try { - execSync( - `vault policy write ${boundaryPolicyName} ./admin/tests/fixtures/boundary-controller-policy.hcl`, + const clientToken = await vaultCli.setupVaultCredentialStore( + boundaryPolicyName, + secretPolicyName, + secretsPath, + secretName, + sshUser, + sshKeyPath, ); - execSync(`vault secrets enable -path=${secretsPath} kv-v2`); - execSync( - `vault kv put -mount ${secretsPath} ${secretName} ` + - ` username=${sshUser}` + - ` private_key=@${sshKeyPath}`, - ); - execSync( - `vault policy write ${secretPolicyName} ./admin/tests/fixtures/kv-policy.hcl`, - ); - const vaultToken = JSON.parse( - execSync( - `vault token create` + - ` -no-default-policy=true` + - ` -policy=${boundaryPolicyName}` + - ` -policy=${secretPolicyName}` + - ` -orphan=true` + - ` -period=20m` + - ` -renewable=true` + - ` -format=json`, - ), - ); - const clientToken = vaultToken.auth.client_token; const orgsPage = new OrgsPage(page); const orgName = await orgsPage.createOrg(); @@ -134,3 +118,49 @@ test( } }, ); + +test( + 'Vault Credential Store - Username, Password & Domain credential type', + { tag: ['@ent', '@aws', '@docker'] }, + async ({ page, sshUser, sshKeyPath, vaultAddrPrivate, workerTagEgress }) => { + let orgId; + try { + // Set up Vault credential store + const clientToken = await vaultCli.setupVaultCredentialStore( + boundaryPolicyName, + secretPolicyName, + secretsPath, + secretName, + sshUser, + sshKeyPath, + ); + const orgsPage = new OrgsPage(page); + await orgsPage.createOrg(); + const projectsPage = new ProjectsPage(page); + await projectsPage.createProject(); + + const credentialStoresPage = new CredentialStoresPage(page); + await credentialStoresPage.createVaultCredentialStoreWithWorkerFilter( + vaultAddrPrivate, + clientToken, + `"${workerTagEgress}" in "/tags/type"`, + ); + + const credentialLibraryName = + await credentialStoresPage.createVaultGenericCredentialLibraryEnt( + `${secretsPath}/data/${secretName}`, + 'Username, Password & Domain', + ); + + await expect( + page + .getByRole('navigation', { name: 'breadcrumbs' }) + .getByText(credentialLibraryName), + ).toBeVisible(); + } finally { + if (orgId) { + await boundaryCli.deleteScope(orgId); + } + } + }, +); diff --git a/e2e-tests/helpers/vault-cli.js b/e2e-tests/helpers/vault-cli.js index 7c6fbd563..b33492e74 100644 --- a/e2e-tests/helpers/vault-cli.js +++ b/e2e-tests/helpers/vault-cli.js @@ -163,3 +163,59 @@ export async function setupVaultOidc( authPolicyName: authPolicyName, }; } + +/** + * Sets up a Vault credential store by configuring policies, enabling secrets, + * and creating a token with specific permissions. + * + * @async + * @function setupVaultCredentialStore + * @param {string} boundaryPolicyName - The name of the boundary policy to be created. + * @param {string} secretPolicyName - The name of the secret policy to be created. + * @param {string} secretsPath - The path where the secrets engine will be enabled. + * @param {string} secretName - The name of the secret to be stored in the secrets engine. + * @param {string} sshUser - The SSH username to be stored in the secret. + * @param {string} sshKeyPath - The file path to the SSH private key to be stored in the secret. + * @returns {Promise} Vault Token + */ +export async function setupVaultCredentialStore( + boundaryPolicyName, + secretPolicyName, + secretsPath, + secretName, + sshUser, + sshKeyPath, +) { + let clientToken; + try { + execSync( + `vault policy write ${boundaryPolicyName} ./admin/tests/fixtures/boundary-controller-policy.hcl`, + ); + execSync(`vault secrets enable -path=${secretsPath} kv-v2`); + execSync( + `vault kv put -mount ${secretsPath} ${secretName} ` + + ` username=${sshUser}` + + ` private_key=@${sshKeyPath}`, + ); + execSync( + `vault policy write ${secretPolicyName} ./admin/tests/fixtures/kv-policy.hcl`, + ); + const vaultToken = JSON.parse( + execSync( + `vault token create \ + -no-default-policy=true \ + -policy=${boundaryPolicyName} \ + -policy=${secretPolicyName} \ + -orphan=true \ + -period=20m \ + -renewable=true \ + -format=json`, + ), + ); + clientToken = vaultToken.auth.client_token; + } catch (e) { + console.log(`${e.stderr}`); + } + + return clientToken; +} 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 b347fbee3..3473a1b65 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 @@ -115,37 +115,53 @@ {{/if}} - - - {{t 'resources.credential-library.form.credential_type.label'}} - - - {{t 'resources.credential-library.form.credential_type.help'}} - - - + {{#if @model.errors.credential_type}} + + {{#each @model.errors.credential_type as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + {{else}} + + {{t + 'resources.credential-library.form.credential_type.label' + }} + + {{t 'resources.credential-library.form.credential_type.description'}} + + + {{/if}} {{#if @model.credential_type}} {{#if @model.isNew}} - - {{t 'form.type.label'}} - {{#each @types as |credentialType|}} - - {{t (concat 'resources.credential.types.' credentialType)}} - - {{t - (concat 'resources.credential.help.' credentialType) - }} - - - {{/each}} - - + {{t 'resources.credential.form.json.label'}} {{t 'resources.credential.form.json.help'}} diff --git a/ui/admin/app/components/form/credential/radio/index.hbs b/ui/admin/app/components/form/credential/radio/index.hbs new file mode 100644 index 000000000..edbbc3c4e --- /dev/null +++ b/ui/admin/app/components/form/credential/radio/index.hbs @@ -0,0 +1,27 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + + + {{t 'form.type.label'}} + {{#each @types as |credentialType|}} + + {{t + (concat 'resources.credential.types.' credentialType) + }} + {{t + (concat 'resources.credential.help.' credentialType) + }} + + {{/each}} + \ No newline at end of file diff --git a/ui/admin/app/components/form/credential/ssh_private_key/index.hbs b/ui/admin/app/components/form/credential/ssh_private_key/index.hbs index 2289cc92c..343d5d18b 100644 --- a/ui/admin/app/components/form/credential/ssh_private_key/index.hbs +++ b/ui/admin/app/components/form/credential/ssh_private_key/index.hbs @@ -51,27 +51,13 @@ {{/if}} - {{#if @model.isNew}} - - {{t 'form.type.label'}} - {{#each @types as |credentialType|}} - - {{t - (concat 'resources.credential.types.' credentialType) - }} - {{t - (concat 'resources.credential.help.' credentialType) - }} - - {{/each}} - + {{#if @model.isNew}} + {{else}} {{/if}} - {{#if @model.isNew}} - - {{t 'form.type.label'}} - {{#each @types as |credentialType|}} - - {{t - (concat 'resources.credential.types.' credentialType) - }} - {{t - (concat 'resources.credential.help.' credentialType) - }} - - - {{/each}} - + {{else}} + + + {{t 'form.name.label'}} + {{t 'form.name.help'}} + {{#if @model.errors.name}} + + {{#each @model.errors.name as |error|}} + + {{error.message}} + + {{/each}} + + {{/if}} + + + + {{t 'form.description.label'}} + {{t 'form.description.help'}} + {{#if @model.errors.description}} + + {{#each @model.errors.description as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + {{#if @model.isNew}} + + {{else}} + + {{t 'form.type.label'}} + {{t (concat 'resources.credential.help.' @model.type)}} + + + {{/if}} + + + {{t 'resources.credential.form.username.label'}} + {{t 'resources.credential.form.username.help'}} + {{#if @model.errors.username}} + + {{#each @model.errors.username as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + {{#if (or @model.isNew form.isEditable)}} + + {{t 'resources.credential.form.password.label'}} + {{t + 'resources.credential.form.password.help' + }} + {{#if @model.errors.password}} + + {{#each @model.errors.password as |error|}} + + {{error.message}} + + {{/each}} + + {{/if}} + + {{/if}} + + + {{t 'resources.credential.form.domain.label'}} + {{t 'resources.credential.form.domain.help'}} + {{#if @model.errors.domain}} + + {{#each @model.errors.domain as |error|}} + {{error.message}} + {{/each}} + + {{/if}} + + + {{#if (can 'save model' @model)}} + + {{/if}} + + \ No newline at end of file diff --git a/ui/admin/app/components/form/credential/username_password_domain/index.js b/ui/admin/app/components/form/credential/username_password_domain/index.js new file mode 100644 index 000000000..8afd432a0 --- /dev/null +++ b/ui/admin/app/components/form/credential/username_password_domain/index.js @@ -0,0 +1,52 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +export default class FormCredentialUsernamePasswordDomainComponent extends Component { + // =actions + + /** + * Pass the username input value for further processing. + */ + @action + handleUsername(event) { + const { value } = event.target; + this.processUsername(value); + } + + /** + * Process the username input and call the submit action + */ + @action + handleSubmit() { + this.processUsername(this.args.model.username); + this.args.submit(); + } + + /** + * Process the username input by splitting it into username and domain + * and updating the model accordingly. + * @param {string} value - The input value from the username field + */ + processUsername(value) { + const arr = value?.split(/[@\\]/) ?? []; + + // Check if the length of the arr is 2 + // This is to avoid cases where the username might be hello@world@again + // We let the server handle the validation in that case + // and only update the model if we have exactly one username and one domain after splitting + if (arr.length === 2) { + const [username, domain] = arr; + + // Update the model with the values only if both username and domain are present + if (username && domain) { + this.args.model.username = username; + this.args.model.domain = domain; + } + } + } +} diff --git a/ui/admin/app/components/form/host-catalog/aws/index.hbs b/ui/admin/app/components/form/host-catalog/aws/index.hbs index 7debb291a..5b1b1c896 100644 --- a/ui/admin/app/components/form/host-catalog/aws/index.hbs +++ b/ui/admin/app/components/form/host-catalog/aws/index.hbs @@ -149,7 +149,7 @@ message) + @notifyError(({ message }) => message, { catch: true }) @notifySuccess(({ isNew }) => isNew ? 'notifications.create-success' : 'notifications.save-success', ) diff --git a/ui/admin/app/styles/app.scss b/ui/admin/app/styles/app.scss index 4c66e097b..a0e112835 100644 --- a/ui/admin/app/styles/app.scss +++ b/ui/admin/app/styles/app.scss @@ -967,7 +967,7 @@ } } -.dynamic-credential-selection { +.credential-selection { .hds-form-group__control-field { margin-bottom: 0; } diff --git a/ui/admin/tests/acceptance/credential-library/create-test.js b/ui/admin/tests/acceptance/credential-library/create-test.js index 2e630989c..352943338 100644 --- a/ui/admin/tests/acceptance/credential-library/create-test.js +++ b/ui/admin/tests/acceptance/credential-library/create-test.js @@ -14,6 +14,7 @@ import { TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE } from 'api/models/creden import * as selectors from './selectors'; import * as commonSelectors from 'admin/tests/helpers/selectors'; import { setRunOptions } from 'ember-a11y-testing/test-support'; +import { TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN } from 'api/models/credential'; module('Acceptance | credential-libraries | create', function (hooks) { setupApplicationTest(hooks); @@ -22,6 +23,7 @@ module('Acceptance | credential-libraries | create', function (hooks) { let featuresService; let getCredentialLibraryCount; + let getUsernamePasswordDomainCredentialLibraryCount; const instances = { scopes: { @@ -73,6 +75,11 @@ module('Acceptance | credential-libraries | create', function (hooks) { // Generate resource counter getCredentialLibraryCount = () => this.server.schema.credentialLibraries.all().models.length; + getUsernamePasswordDomainCredentialLibraryCount = () => { + return this.server.schema.credentialLibraries.where({ + credentialType: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + }).length; + }; await authenticateSession({ username: 'admin' }); featuresService = this.owner.lookup('service:features'); }); @@ -110,10 +117,13 @@ module('Acceptance | credential-libraries | create', function (hooks) { await visit(urls.newCredentialLibrary); await fillIn(commonSelectors.FIELD_NAME, commonSelectors.FIELD_NAME_VALUE); - await select(selectors.FIELD_CRED_TYPE, selectors.FIELD_CRED_TYPE_VALUE); + await select( + selectors.FIELD_CRED_TYPE, + selectors.FIELD_CRED_TYPE_SSH_VALUE, + ); await select( selectors.FIELD_CRED_MAP_OVERRIDES_SELECT, - selectors.FIELD_CRED_MAP_OVERRIDES_SELECT_VALUE, + selectors.FIELD_CRED_MAP_OVERRIDES_SELECT_SSH_VALUE, ); await fillIn(selectors.FIELD_CRED_MAP_OVERRIDES_INPUT, 'key'); await click(selectors.FIELD_CRED_MAP_OVERRIDES_BTN); @@ -129,13 +139,58 @@ module('Acceptance | credential-libraries | create', function (hooks) { ); assert.strictEqual( credentialLibrary.credentialType, - selectors.FIELD_CRED_TYPE_VALUE, + selectors.FIELD_CRED_TYPE_SSH_VALUE, ); assert.deepEqual(credentialLibrary.credentialMappingOverrides, { private_key_attribute: 'key', }); }); + test('can create a new credential library with username, password and domain type for vault generic', async function (assert) { + const credentialLibraryCount = getCredentialLibraryCount(); + const usernamePasswordDomainCredentialLibraryCount = + getUsernamePasswordDomainCredentialLibraryCount(); + await visit(urls.newCredentialLibrary); + + await fillIn(selectors.FIELD_VAULT_PATH, selectors.FIELD_VAULT_PATH_VALUE); + await fillIn(commonSelectors.FIELD_NAME, commonSelectors.FIELD_NAME_VALUE); + await select( + selectors.FIELD_CRED_TYPE, + selectors.FIELD_CRED_TYPE_UPD_VALUE, + ); + + await select( + selectors.FIELD_CRED_MAP_OVERRIDES_SELECT, + selectors.FIELD_CRED_MAP_OVERRIDES_SELECT_DOMAIN_VALUE, + ); + await fillIn(selectors.FIELD_CRED_MAP_OVERRIDES_INPUT, 'domain'); + + await click(selectors.FIELD_CRED_MAP_OVERRIDES_BTN); + await click(commonSelectors.SAVE_BTN); + + assert.strictEqual(getCredentialLibraryCount(), credentialLibraryCount + 1); + assert.strictEqual( + getUsernamePasswordDomainCredentialLibraryCount(), + usernamePasswordDomainCredentialLibraryCount + 1, + ); + + const credentialLibrary = this.server.schema.credentialLibraries.findBy({ + credentialType: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + }); + + assert.strictEqual( + credentialLibrary.name, + commonSelectors.FIELD_NAME_VALUE, + ); + assert.strictEqual( + credentialLibrary.credentialType, + selectors.FIELD_CRED_TYPE_UPD_VALUE, + ); + assert.deepEqual(credentialLibrary.credentialMappingOverrides, { + domain_attribute: 'domain', + }); + }); + test('can create a new credential library of type vault ssh cert', async function (assert) { setRunOptions({ rules: { diff --git a/ui/admin/tests/acceptance/credential-library/delete-test.js b/ui/admin/tests/acceptance/credential-library/delete-test.js index be76b102e..93e903ac7 100644 --- a/ui/admin/tests/acceptance/credential-library/delete-test.js +++ b/ui/admin/tests/acceptance/credential-library/delete-test.js @@ -15,6 +15,8 @@ import sinon from 'sinon'; import * as selectors from './selectors'; import * as commonSelectors from 'admin/tests/helpers/selectors'; import { setRunOptions } from 'ember-a11y-testing/test-support'; +import { TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC } from 'api/models/credential-library'; +import { TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN } from 'api/models/credential'; module('Acceptance | credential-libraries | delete', function (hooks) { setupApplicationTest(hooks); @@ -22,6 +24,7 @@ module('Acceptance | credential-libraries | delete', function (hooks) { setupSqlite(hooks); let getCredentialLibraryCount; + let getUsernamePasswordDomainCredentialLibraryCount; const instances = { scopes: { @@ -38,6 +41,7 @@ module('Acceptance | credential-libraries | delete', function (hooks) { credentialLibraries: null, newCredentialLibrary: null, unknownCredentialLibrary: null, + usernamePasswordDomainCredentialLibrary: null, }; hooks.beforeEach(async function () { @@ -58,6 +62,15 @@ module('Acceptance | credential-libraries | delete', function (hooks) { scope: instances.scopes.project, credentialStore: instances.credentialStore, }); + instances.usernamePasswordDomainCredentialLibrary = this.server.create( + 'credential-library', + { + scope: instances.scopes.project, + credentialStore: instances.credentialStore, + type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, + credential_type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + }, + ); // Generate route URLs for resources urls.credentialStores = `/scopes/${instances.scopes.project.id}/credential-stores`; urls.credentialStore = `${urls.credentialStores}/${instances.credentialStore.id}`; @@ -65,9 +78,15 @@ module('Acceptance | credential-libraries | delete', function (hooks) { urls.credentialLibrary = `${urls.credentialLibraries}/${instances.credentialLibrary.id}`; urls.newCredentialLibrary = `${urls.credentialLibraries}/new`; urls.unknownCredentialLibrary = `${urls.credentialLibraries}/foo`; + urls.usernamePasswordDomainCredentialLibrary = `${urls.credentialLibraries}/${instances.usernamePasswordDomainCredentialLibrary.id}`; // Generate resource counter getCredentialLibraryCount = () => this.server.schema.credentialLibraries.all().models.length; + getUsernamePasswordDomainCredentialLibraryCount = () => { + return this.server.schema.credentialLibraries.where({ + credential_type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + }).length; + }; await authenticateSession({}); }); @@ -175,4 +194,18 @@ module('Acceptance | credential-libraries | delete', function (hooks) { assert.dom(commonSelectors.ALERT_TOAST_BODY).hasText('Oops.'); }); + + test('can delete username password and domain credential library type', async function (assert) { + const usernamePasswordDomainCredentialLibraryCount = + getUsernamePasswordDomainCredentialLibraryCount(); + await visit(urls.usernamePasswordDomainCredentialLibrary); + + await click(selectors.MANAGE_DROPDOWN_CRED_LIB); + await click(selectors.MANAGE_DROPDOWN_CRED_LIB_DELETE); + + assert.strictEqual( + getUsernamePasswordDomainCredentialLibraryCount(), + usernamePasswordDomainCredentialLibraryCount - 1, + ); + }); }); diff --git a/ui/admin/tests/acceptance/credential-library/read-test.js b/ui/admin/tests/acceptance/credential-library/read-test.js index 3d9520fe4..7966a6c0e 100644 --- a/ui/admin/tests/acceptance/credential-library/read-test.js +++ b/ui/admin/tests/acceptance/credential-library/read-test.js @@ -9,9 +9,13 @@ import { setupApplicationTest } from 'admin/tests/helpers'; import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; import { setupSqlite } from 'api/test-support/helpers/sqlite'; import { authenticateSession } from 'ember-simple-auth/test-support'; -import { TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE } from 'api/models/credential-library'; +import { + TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, + TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, +} from 'api/models/credential-library'; import * as commonSelectors from 'admin/tests/helpers/selectors'; import { setRunOptions } from 'ember-a11y-testing/test-support'; +import { TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN } from 'api/models/credential'; module('Acceptance | credential-libraries | read', function (hooks) { setupApplicationTest(hooks); @@ -169,4 +173,21 @@ module('Acceptance | credential-libraries | read', function (hooks) { assert.notEqual(currentURL(), incorrectUrl); assert.strictEqual(currentURL(), correctUrl); }); + + test('visiting vault credential library of type username password and domain', async function (assert) { + const usernamePasswordDomainCredentialLibrary = this.server.create( + 'credential-library', + { + scope: instances.scopes.project, + credentialStore: instances.credentialStore, + type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, + credential_type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + }, + ); + const url = `${urls.credentialLibraries}/${usernamePasswordDomainCredentialLibrary.id}`; + + await visit(url); + + assert.strictEqual(currentURL(), url); + }); }); diff --git a/ui/admin/tests/acceptance/credential-library/selectors.js b/ui/admin/tests/acceptance/credential-library/selectors.js index 5bd415366..aa4de05b7 100644 --- a/ui/admin/tests/acceptance/credential-library/selectors.js +++ b/ui/admin/tests/acceptance/credential-library/selectors.js @@ -4,7 +4,8 @@ */ export const FIELD_CRED_TYPE = '[name=credential_type]'; -export const FIELD_CRED_TYPE_VALUE = 'ssh_private_key'; +export const FIELD_CRED_TYPE_SSH_VALUE = 'ssh_private_key'; +export const FIELD_CRED_TYPE_UPD_VALUE = 'username_password_domain'; export const FIELD_VAULT_PATH = '[name=vault_path]'; export const FIELD_VAULT_PATH_VALUE = 'path'; @@ -27,7 +28,9 @@ export const FIELD_KEY_ID_VALUE = 'key_id'; export const FIELD_CRED_MAP_OVERRIDES_SELECT = '[name="credential_mapping_overrides"] select'; -export const FIELD_CRED_MAP_OVERRIDES_SELECT_VALUE = 'private_key_attribute'; +export const FIELD_CRED_MAP_OVERRIDES_SELECT_SSH_VALUE = + 'private_key_attribute'; +export const FIELD_CRED_MAP_OVERRIDES_SELECT_DOMAIN_VALUE = 'domain_attribute'; export const FIELD_CRED_MAP_OVERRIDES_INPUT = '[name="credential_mapping_overrides"] input'; export const FIELD_CRED_MAP_OVERRIDES_BTN = diff --git a/ui/admin/tests/acceptance/credential-library/update-test.js b/ui/admin/tests/acceptance/credential-library/update-test.js index 5caccc8cc..1a38049fa 100644 --- a/ui/admin/tests/acceptance/credential-library/update-test.js +++ b/ui/admin/tests/acceptance/credential-library/update-test.js @@ -10,10 +10,14 @@ import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; import { setupSqlite } from 'api/test-support/helpers/sqlite'; import { authenticateSession } from 'ember-simple-auth/test-support'; import { Response } from 'miragejs'; -import { TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE } from 'api/models/credential-library'; +import { + TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, + TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, +} from 'api/models/credential-library'; import * as selectors from './selectors'; import * as commonSelectors from 'admin/tests/helpers/selectors'; import { setRunOptions } from 'ember-a11y-testing/test-support'; +import { TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN } from 'api/models/credential'; module('Acceptance | credential-libraries | update', function (hooks) { setupApplicationTest(hooks); @@ -38,6 +42,7 @@ module('Acceptance | credential-libraries | update', function (hooks) { credentialLibraries: null, newCredentialLibrary: null, unknownCredentialLibrary: null, + usernamePasswordDomainCredentialLibrary: null, }; hooks.beforeEach(async function () { @@ -58,6 +63,15 @@ module('Acceptance | credential-libraries | update', function (hooks) { scope: instances.scopes.project, credentialStore: instances.credentialStore, }); + instances.usernamePasswordDomainCredentialLibrary = this.server.create( + 'credential-library', + { + scope: instances.scopes.project, + credentialStore: instances.credentialStore, + type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, + credential_type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + }, + ); // Generate route URLs for resources urls.globalScope = `/scopes/global/scopes`; urls.orgScope = `/scopes/${instances.scopes.org.id}/scopes`; @@ -68,6 +82,7 @@ module('Acceptance | credential-libraries | update', function (hooks) { urls.credentialLibrary = `${urls.credentialLibraries}/${instances.credentialLibrary.id}`; urls.newCredentialLibrary = `${urls.credentialLibraries}/new`; urls.unknownCredentialLibrary = `${urls.credentialLibraries}/foo`; + urls.usernamePasswordDomainCredentialLibrary = `${urls.credentialLibraries}/${instances.usernamePasswordDomainCredentialLibrary.id}`; await authenticateSession({}); }); @@ -127,7 +142,7 @@ module('Acceptance | credential-libraries | update', function (hooks) { await fillIn(selectors.FIELD_VAULT_PATH, selectors.FIELD_VAULT_PATH_VALUE); await select( selectors.FIELD_CRED_MAP_OVERRIDES_SELECT, - selectors.FIELD_CRED_MAP_OVERRIDES_SELECT_VALUE, + selectors.FIELD_CRED_MAP_OVERRIDES_SELECT_SSH_VALUE, ); await fillIn(selectors.FIELD_CRED_MAP_OVERRIDES_INPUT, 'key'); await click(selectors.FIELD_CRED_MAP_OVERRIDES_BTN); @@ -153,6 +168,45 @@ module('Acceptance | credential-libraries | update', function (hooks) { }); }); + test('can update a vault generic credential library of username, password and domain type and save changes', async function (assert) { + await visit(urls.usernamePasswordDomainCredentialLibrary); + + await click(commonSelectors.EDIT_BTN); + await fillIn(commonSelectors.FIELD_NAME, commonSelectors.FIELD_NAME_VALUE); + await fillIn( + commonSelectors.FIELD_DESCRIPTION, + commonSelectors.FIELD_DESCRIPTION_VALUE, + ); + await fillIn(selectors.FIELD_VAULT_PATH, selectors.FIELD_VAULT_PATH_VALUE); + await select( + selectors.FIELD_CRED_MAP_OVERRIDES_SELECT, + selectors.FIELD_CRED_MAP_OVERRIDES_SELECT_DOMAIN_VALUE, + ); + await fillIn(selectors.FIELD_CRED_MAP_OVERRIDES_INPUT, 'domain'); + + await click(selectors.FIELD_CRED_MAP_OVERRIDES_BTN); + await click(commonSelectors.SAVE_BTN); + + const credentialLibrary = this.server.schema.credentialLibraries.findBy({ + credentialType: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + }); + assert.strictEqual( + credentialLibrary.name, + commonSelectors.FIELD_NAME_VALUE, + ); + assert.strictEqual( + credentialLibrary.description, + commonSelectors.FIELD_DESCRIPTION_VALUE, + ); + assert.strictEqual( + credentialLibrary.attributes.path, + selectors.FIELD_VAULT_PATH_VALUE, + ); + assert.deepEqual(credentialLibrary.credentialMappingOverrides, { + domain_attribute: 'domain', + }); + }); + test('saving an existing credential library with invalid fields displays error messages', async function (assert) { setRunOptions({ rules: { @@ -288,7 +342,7 @@ module('Acceptance | credential-libraries | update', function (hooks) { await visit(urls.credentialLibrary); await click(commonSelectors.EDIT_BTN, 'Activate edit mode'); - assert.dom(selectors.FIELD_CRED_TYPE).isDisabled(); + assert.dom(selectors.FIELD_CRED_TYPE).doesNotExist(); }); test('can update a vault ssh cert credential library and save changes', async function (assert) { diff --git a/ui/admin/tests/acceptance/credential-store/credentials/create-test.js b/ui/admin/tests/acceptance/credential-store/credentials/create-test.js index 637bacb3b..7dbc9cd04 100644 --- a/ui/admin/tests/acceptance/credential-store/credentials/create-test.js +++ b/ui/admin/tests/acceptance/credential-store/credentials/create-test.js @@ -24,6 +24,7 @@ module( let getCredentialsCount; let getUsernamePasswordCredentialCount; let getUsernameKeyPairCredentialCount; + let getUsernamePasswordDomainCredentialCount; let getJsonCredentialCount; let featuresService; @@ -80,6 +81,11 @@ module( getJsonCredentialCount = () => { return this.server.schema.credentials.where({ type: 'json' }).length; }; + getUsernamePasswordDomainCredentialCount = () => { + return this.server.schema.credentials.where({ + type: 'username_password_domain' + }).length; + }; await authenticateSession({}); }); @@ -112,6 +118,61 @@ module( ); }); + test('users can create a new username, password & domain credential', async function (assert) { + const credentialsCount = getCredentialsCount(); + const usernamePasswordDomainCredentialCount = + getUsernamePasswordDomainCredentialCount(); + await visit(urls.credentials); + + await click(commonSelectors.HREF(urls.newCredential)); + await fillIn( + commonSelectors.FIELD_NAME, + commonSelectors.FIELD_NAME_VALUE, + ); + await click(selectors.FIELD_TYPE_USERNAME_PASSWORD_DOMAIN); + + await fillIn(selectors.FIELD_USERNAME, selectors.FIELD_USERNAME_VALUE); + await fillIn(selectors.FIELD_PASSWORD, selectors.FIELD_PASSWORD_VALUE); + await fillIn(selectors.FIELD_DOMAIN, selectors.FIELD_DOMAIN_VALUE); + + await click(commonSelectors.SAVE_BTN); + + assert.strictEqual(getCredentialsCount(), credentialsCount + 1); + assert.strictEqual( + getUsernamePasswordDomainCredentialCount(), + usernamePasswordDomainCredentialCount + 1, + ); + }); + + test('users can create a new username & password credential with domain in username field', async function (assert) { + const credentialsCount = getCredentialsCount(); + const usernamePasswordDomainCredentialCount = + getUsernamePasswordDomainCredentialCount(); + await visit(urls.credentials); + + await click(commonSelectors.HREF(urls.newCredential)); + await fillIn( + commonSelectors.FIELD_NAME, + commonSelectors.FIELD_NAME_VALUE, + ); + await click(selectors.FIELD_TYPE_USERNAME_PASSWORD_DOMAIN); + await fillIn(selectors.FIELD_USERNAME, selectors.FIELD_USERNAME_WITH_DOMAIN_VALUE); + await fillIn(selectors.FIELD_PASSWORD, selectors.FIELD_PASSWORD_VALUE); + + + // check that the domain field is filled in + assert.dom(selectors.FIELD_DOMAIN).hasValue(selectors.FIELD_DOMAIN_VALUE); + + await click(commonSelectors.SAVE_BTN); + + assert.strictEqual(getCredentialsCount(), credentialsCount + 1); + assert.strictEqual( + getUsernamePasswordDomainCredentialCount(), + usernamePasswordDomainCredentialCount + 1, + ); + }); + + test('users can create a new username & key pair credential', async function (assert) { setRunOptions({ rules: { @@ -254,6 +315,22 @@ module( assert.strictEqual(getCredentialsCount(), credentialsCount); }); + test('users can cancel creation of new username, password & domain credential', async function (assert) { + const credentialsCount = getCredentialsCount(); + await visit(urls.credentials); + + await click(commonSelectors.HREF(urls.newCredential)); + await fillIn( + commonSelectors.FIELD_NAME, + commonSelectors.FIELD_NAME_VALUE, + ); + await click(selectors.FIELD_TYPE_USERNAME_PASSWORD_DOMAIN); + await click(commonSelectors.CANCEL_BTN); + + assert.strictEqual(currentURL(), urls.credentials); + assert.strictEqual(getCredentialsCount(), credentialsCount); + }); + test('users can switch away from JSON type credentials and the json_object value will be cleared', async function (assert) { setRunOptions({ rules: { @@ -397,6 +474,42 @@ module( .hasText(errorDescription); }); + test('saving a new username, password & domain credential with invalid fields displays error messages', async function (assert) { + const errorMessage = 'Error in provided request.'; + const errorDescription = + 'Field required for creating a username-password-domain credential.'; + await visit(urls.credentials); + + this.server.post('/credentials', () => { + return new Response( + 400, + {}, + { + status: 400, + code: 'invalid_argument', + message: errorMessage, + details: { + request_fields: [ + { + name: 'attributes.domain', + description: errorDescription, + }, + ], + }, + }, + ); + } + ); + await click(commonSelectors.HREF(urls.newCredential)); + await click(selectors.FIELD_TYPE_USERNAME_PASSWORD_DOMAIN); + await click(commonSelectors.SAVE_BTN); + assert.dom(commonSelectors.ALERT_TOAST_BODY).hasText(errorMessage); + + assert + .dom(selectors.FIELD_DOMAIN_ERROR) + .hasText('Field required for creating a username-password-domain credential.'); + }); + test('saving a new json credential with invalid fields displays error messages', async function (assert) { setRunOptions({ rules: { diff --git a/ui/admin/tests/acceptance/credential-store/credentials/delete-test.js b/ui/admin/tests/acceptance/credential-store/credentials/delete-test.js index abc6cd52c..2596dd6c1 100644 --- a/ui/admin/tests/acceptance/credential-store/credentials/delete-test.js +++ b/ui/admin/tests/acceptance/credential-store/credentials/delete-test.js @@ -13,6 +13,11 @@ import { Response } from 'miragejs'; import * as selectors from './selectors'; import * as commonSelectors from 'admin/tests/helpers/selectors'; import { setRunOptions } from 'ember-a11y-testing/test-support'; +import { + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + TYPE_CREDENTIAL_SSH_PRIVATE_KEY, + TYPE_CREDENTIAL_USERNAME_PASSWORD, +} from 'api/models/credential'; module( 'Acceptance | credential-stores | credentials | delete', @@ -24,6 +29,7 @@ module( let getUsernamePasswordCredentialCount; let getUsernameKeyPairCredentialCount; let getJSONCredentialCount; + let getUsernamePasswordDomainCredentialCount; const mockResponseMessage = 'Oops.'; const mockResponse = () => { @@ -53,6 +59,7 @@ module( usernamePasswordCredential: null, usernameKeyPairCredential: null, jsonCredential: null, + usernamePasswordDomainCredential: null, }; hooks.beforeEach(async function () { @@ -72,13 +79,21 @@ module( instances.usernamePasswordCredential = this.server.create('credential', { scope: instances.scopes.project, credentialStore: instances.staticCredentialStore, - type: 'username_password', + type: TYPE_CREDENTIAL_USERNAME_PASSWORD, }); instances.usernameKeyPairCredential = this.server.create('credential', { scope: instances.scopes.project, credentialStore: instances.staticCredentialStore, - type: 'ssh_private_key', + type: TYPE_CREDENTIAL_SSH_PRIVATE_KEY, }); + instances.usernamePasswordDomainCredential = this.server.create( + 'credential', + { + scope: instances.scopes.project, + credentialStore: instances.staticCredentialStore, + type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + }, + ); instances.jsonCredential = this.server.create('credential', { scope: instances.scopes.project, credentialStore: instances.staticCredentialStore, @@ -91,20 +106,27 @@ module( urls.credentials = `${urls.staticCredentialStore}/credentials`; urls.usernamePasswordCredential = `${urls.credentials}/${instances.usernamePasswordCredential.id}`; urls.usernameKeyPairCredential = `${urls.credentials}/${instances.usernameKeyPairCredential.id}`; + urls.usernamePasswordDomainCredential = `${urls.credentials}/${instances.usernamePasswordDomainCredential.id}`; urls.jsonCredential = `${urls.credentials}/${instances.jsonCredential.id}`; // Generate resource counter getUsernamePasswordCredentialCount = () => { return this.server.schema.credentials.where({ - type: 'username_password', + type: TYPE_CREDENTIAL_USERNAME_PASSWORD, }).length; }; getUsernameKeyPairCredentialCount = () => { - return this.server.schema.credentials.where({ type: 'ssh_private_key' }) - .length; + return this.server.schema.credentials.where({ + type: TYPE_CREDENTIAL_SSH_PRIVATE_KEY, + }).length; }; getJSONCredentialCount = () => { return this.server.schema.credentials.where({ type: 'json' }).length; }; + getUsernamePasswordDomainCredentialCount = () => { + return this.server.schema.credentials.where({ + type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + }).length; + }; await authenticateSession({}); }); @@ -176,6 +198,22 @@ module( assert.strictEqual(getJSONCredentialCount(), jsonCredentialCount - 1); }); + test('can delete username, password & domain credential', async function (assert) { + const usernamePasswordDomainCredentialCount = + getUsernamePasswordDomainCredentialCount(); + + await visit(urls.usernamePasswordDomainCredential); + + await click(selectors.MANAGE_DROPDOWN); + await click(selectors.MANAGE_DROPDOWN_DELETE); + + assert.strictEqual(currentURL(), urls.credentials); + assert.strictEqual( + getUsernamePasswordDomainCredentialCount(), + usernamePasswordDomainCredentialCount - 1, + ); + }); + test('cannot delete a username & password credential without proper authorization', async function (assert) { const usernamePasswordCredentialCount = getUsernamePasswordCredentialCount(); @@ -223,6 +261,23 @@ module( assert.strictEqual(getJSONCredentialCount(), jsonCredentialCount); }); + test('cannot delete a username, password & domain credential without proper authorization', async function (assert) { + const usernamePasswordDomainCredentialCount = + getUsernamePasswordDomainCredentialCount(); + instances.usernamePasswordDomainCredential.authorized_actions = + instances.usernamePasswordDomainCredential.authorized_actions.filter( + (item) => item !== 'delete', + ); + await visit(urls.usernamePasswordDomainCredential); + + assert.strictEqual(currentURL(), urls.usernamePasswordDomainCredential); + assert.dom(selectors.MANAGE_DROPDOWN).doesNotExist(); + assert.strictEqual( + getUsernamePasswordDomainCredentialCount(), + usernamePasswordDomainCredentialCount, + ); + }); + test('can accept delete username & password credential via dialog', async function (assert) { setRunOptions({ rules: { @@ -353,6 +408,24 @@ module( ); }); + test('can cancel delete username, password & domain credential via dialog', async function (assert) { + const confirmService = this.owner.lookup('service:confirm'); + confirmService.enabled = true; + const usernamePasswordDomainCredentialCount = + getUsernamePasswordDomainCredentialCount(); + await visit(urls.usernamePasswordDomainCredential); + + await click(selectors.MANAGE_DROPDOWN); + await click(selectors.MANAGE_DROPDOWN_DELETE); + await click(commonSelectors.MODAL_WARNING_CANCEL_BTN); + + assert.strictEqual(currentURL(), urls.usernamePasswordDomainCredential); + assert.strictEqual( + getUsernamePasswordDomainCredentialCount(), + usernamePasswordDomainCredentialCount, + ); + }); + test('can cancel delete JSON credential via dialog', async function (assert) { setRunOptions({ rules: { @@ -432,5 +505,25 @@ module( assert.dom(commonSelectors.ALERT_TOAST_BODY).hasText(mockResponseMessage); }); + + test('deleting a username, password & domain credential which errors displays error message', async function (assert) { + this.server.del('/credentials/:id', () => { + return new Response( + 490, + {}, + { + status: 490, + code: 'error', + message: 'Oops.', + }, + ); + }); + await visit(urls.usernamePasswordDomainCredential); + + await click(selectors.MANAGE_DROPDOWN); + await click(selectors.MANAGE_DROPDOWN_DELETE); + + assert.dom(commonSelectors.ALERT_TOAST_BODY).hasText('Oops.'); + }); }, ); diff --git a/ui/admin/tests/acceptance/credential-store/credentials/read-test.js b/ui/admin/tests/acceptance/credential-store/credentials/read-test.js index e17ca0425..2602dc615 100644 --- a/ui/admin/tests/acceptance/credential-store/credentials/read-test.js +++ b/ui/admin/tests/acceptance/credential-store/credentials/read-test.js @@ -28,6 +28,7 @@ module('Acceptance | credential-stores | credentials | read', function (hooks) { usernamePasswordCredential: null, usernameKeyPairCredential: null, jsonCredential: null, + usernamePasswordDomainCredential: null, }; const urls = { @@ -39,6 +40,7 @@ module('Acceptance | credential-stores | credentials | read', function (hooks) { usernameKeyPairCredential: null, jsonCredential: null, unknownCredential: null, + usernamePasswordDomainCredential: null, }; hooks.beforeEach(async function () { @@ -70,6 +72,15 @@ module('Acceptance | credential-stores | credentials | read', function (hooks) { credentialStore: instances.staticCredentialStore, type: 'json', }); + instances.usernamePasswordDomainCredential = this.server.create( + 'credential', + { + scope: instances.scopes.project, + credentialStore: instances.staticCredentialStore, + type: 'username_password_domain', + }, + ); + // Generate route URLs for resources urls.projectScope = `/scopes/${instances.scopes.project.id}`; urls.credentialStores = `${urls.projectScope}/credential-stores`; @@ -78,6 +89,7 @@ module('Acceptance | credential-stores | credentials | read', function (hooks) { urls.usernamePasswordCredential = `${urls.credentials}/${instances.usernamePasswordCredential.id}`; urls.usernameKeyPairCredential = `${urls.credentials}/${instances.usernameKeyPairCredential.id}`; urls.jsonCredential = `${urls.credentials}/${instances.jsonCredential.id}`; + urls.usernamePasswordDomainCredential = `${urls.credentials}/${instances.usernamePasswordDomainCredential.id}`; urls.unknownCredential = `${urls.credentials}/foo`; await authenticateSession({ username: 'admin' }); featuresService = this.owner.lookup('service:features'); @@ -144,6 +156,20 @@ module('Acceptance | credential-stores | credentials | read', function (hooks) { assert.strictEqual(currentURL(), urls.jsonCredential); }); + test('visiting username, password & domain credential', async function (assert) { + await visit(urls.staticCredentialStore); + + await click(commonSelectors.HREF(urls.credentials)); + await a11yAudit(); + + assert.strictEqual(currentURL(), urls.credentials); + + await click(commonSelectors.HREF(urls.usernamePasswordDomainCredential)); + + await a11yAudit(); + assert.strictEqual(currentURL(), urls.usernamePasswordDomainCredential); + }); + test('cannot navigate to a username & password credential form without proper authorization', async function (assert) { setRunOptions({ rules: { @@ -216,6 +242,22 @@ module('Acceptance | credential-stores | credentials | read', function (hooks) { .doesNotExist(); }); + test('cannot navigate to a username, password & domain credential form without proper authorization', async function (assert) { + instances.usernamePasswordDomainCredential.authorized_actions = + instances.usernamePasswordDomainCredential.authorized_actions.filter( + (item) => item != 'read', + ); + await visit(urls.credentials); + + assert + .dom( + commonSelectors.TABLE_RESOURCE_LINK( + urls.usernamePasswordDomainCredential, + ), + ) + .doesNotExist(); + }); + test('cannot navigate to a JSON credential form when feature not enabled', async function (assert) { setRunOptions({ rules: { diff --git a/ui/admin/tests/acceptance/credential-store/credentials/selectors.js b/ui/admin/tests/acceptance/credential-store/credentials/selectors.js index 0e34b5c24..c90a4b6aa 100644 --- a/ui/admin/tests/acceptance/credential-store/credentials/selectors.js +++ b/ui/admin/tests/acceptance/credential-store/credentials/selectors.js @@ -5,11 +5,23 @@ export const FIELD_TYPE_SSH = '[value=ssh_private_key]'; export const FIELD_TYPE_USERNAME_PASSWORD = '[value=username_password]'; +export const FIELD_TYPE_USERNAME_PASSWORD_DOMAIN = + '[value=username_password_domain]'; +export const FIELD_DOMAIN = + '[name=domain]'; +export const FIELD_PASSWORD = '[name=password]'; +export const FIELD_USERNAME = '[name=username]'; export const FIELD_TYPE_JSON = '[value=json]'; +export const FIELD_DOMAIN_VALUE = 'g.com'; +export const FIELD_USERNAME_VALUE = 'username123'; +export const FIELD_PASSWORD_VALUE = 'password123'; +export const FIELD_USERNAME_WITH_DOMAIN_VALUE = 'username@g.com'; + export const FIELD_EDITOR = '[data-test-code-editor-field-editor] textarea'; export const FIELD_EDITOR_VALUE = '{"test": "value"}'; export const FIELD_PASSWORD_ERROR = '[data-test-error-message-password]'; export const FIELD_SSH_PRIVATE_KEY = '[name=private_key]'; +export const FIELD_DOMAIN_ERROR = '[data-test-error-message-domain]'; export const FIELD_SSH_PRIVATE_KEY_ERROR = '[data-test-error-message-private-key]'; export const FIELD_SSH_PRIVATE_KEY_PASSPHRASE = '[name=private_key_passphrase]'; diff --git a/ui/admin/tests/acceptance/credential-store/credentials/update-test.js b/ui/admin/tests/acceptance/credential-store/credentials/update-test.js index 8d2a5562b..0bc2c1848 100644 --- a/ui/admin/tests/acceptance/credential-store/credentials/update-test.js +++ b/ui/admin/tests/acceptance/credential-store/credentials/update-test.js @@ -40,6 +40,7 @@ module( usernamePasswordCredential: null, usernameKeyPairCredential: null, jsonCredential: null, + usernamePasswordDomainCredential: null, }; const urls = { @@ -50,6 +51,7 @@ module( usernamePasswordCredential: null, usernameKeyPairCredential: null, jsonCredential: null, + usernamePasswordDomainCredential: null, }; const mockResponseMessage = 'Error in provided request.'; @@ -104,6 +106,15 @@ module( credentialStore: instances.staticCredentialStore, type: 'json', }); + instances.usernamePasswordDomainCredential = this.server.create( + 'credential', + { + scope: instances.scopes.project, + credentialStore: instances.staticCredentialStore, + type: 'username_password_domain', + }, + ); + // Generate route URLs for resources urls.projectScope = `/scopes/${instances.scopes.project.id}`; urls.credentialStores = `${urls.projectScope}/credential-stores`; @@ -112,6 +123,7 @@ module( urls.usernamePasswordCredential = `${urls.credentials}/${instances.usernamePasswordCredential.id}`; urls.usernameKeyPairCredential = `${urls.credentials}/${instances.usernameKeyPairCredential.id}`; urls.jsonCredential = `${urls.credentials}/${instances.jsonCredential.id}`; + urls.usernamePasswordDomainCredential = `${urls.credentials}/${instances.usernamePasswordDomainCredential.id}`; featuresService = this.owner.lookup('service:features'); await authenticateSession({}); @@ -192,6 +204,26 @@ module( ); }); + test('can save changes to existing username, password & domain credential', async function (assert) { + const mockInput = 'random string'; + + await visit(urls.usernamePasswordDomainCredential); + + await click('form [type="button"]', 'Activate edit mode'); + await fillIn(selectors.FIELD_USERNAME, mockInput); + await fillIn(selectors.FIELD_DOMAIN, 'g.com'); + await click(commonSelectors.SAVE_BTN); + + assert.strictEqual(currentURL(), urls.usernamePasswordDomainCredential); + + const credential = this.server.schema.credentials.where({ + type: 'username_password_domain', + }).models[0]; + + assert.strictEqual(credential.attributes.username, mockInput); + assert.strictEqual(credential.attributes.domain, 'g.com'); + }); + test('cannot make changes to an existing username & password credential without proper authorization', async function (assert) { instances.usernamePasswordCredential.authorized_actions = instances.usernamePasswordCredential.authorized_actions.filter( @@ -558,6 +590,19 @@ module( } }); + test('can cancel unsaved username, password & domain credential changes via dialog', async function (assert) { + const name = instances.usernamePasswordDomainCredential.name; + await visit(urls.usernamePasswordDomainCredential); + + await click(commonSelectors.EDIT_BTN, 'Activate edit mode'); + await fillIn('[name="username"]', 'random string'); + + // cancel changes + await click(commonSelectors.CANCEL_BTN); + + assert.strictEqual(instances.usernamePasswordDomainCredential.name, name); + }); + test('can cancel discard unsaved JSON credential changes via dialog', async function (assert) { assert.expect(6); const confirmService = this.owner.lookup('service:confirm'); diff --git a/ui/admin/tests/acceptance/host-catalogs/selectors.js b/ui/admin/tests/acceptance/host-catalogs/selectors.js index 2efc37631..c7a1b8a5d 100644 --- a/ui/admin/tests/acceptance/host-catalogs/selectors.js +++ b/ui/admin/tests/acceptance/host-catalogs/selectors.js @@ -30,7 +30,7 @@ export const FIELD_ROLE_ARN = '[name="role_arn"]'; export const FIELD_ROLE_ARN_VALUE = 'arn:aws:iam'; export const FIELD_CREDENTIAL_TYPE = - '.dynamic-credential-selection input:checked'; + '.credential-selection input:checked'; export const FIELD_DYNAMIC_CREDENTIAL = '[name="dynamic-credential"]'; export const FIELD_DYNAMIC_CREDENTIAL_VALUE = 'dynamic-credentials'; export const FIELD_STATIC_CREDENTIAL = '[name="static-credential"]'; diff --git a/ui/admin/tests/acceptance/targets/brokered-credential-sources-test.js b/ui/admin/tests/acceptance/targets/brokered-credential-sources-test.js index 1a8cf3ecc..f9b3cdedf 100644 --- a/ui/admin/tests/acceptance/targets/brokered-credential-sources-test.js +++ b/ui/admin/tests/acceptance/targets/brokered-credential-sources-test.js @@ -75,7 +75,7 @@ module('Acceptance | targets | brokered credential sources', function (hooks) { type: 'static', scope: instances.scopes.project, }); - instances.credentials = this.server.createList('credential', 3, { + instances.credentials = this.server.createList('credential', 4, { scope: instances.scopes.project, credentialStore: instances.staticCredentialStore, }); @@ -115,7 +115,7 @@ module('Acceptance | targets | brokered credential sources', function (hooks) { urls.brokeredCredentialSources = `${urls.target}/brokered-credential-sources`; urls.credentialLibrary = `${urls.projectScope}/credential-stores/${instances.credentialLibrary.credentialStoreId}/credential-libraries/${instances.credentialLibrary.id}`; urls.credential = `${urls.projectScope}/credential-stores/${instances.credential.credentialStoreId}/credentials/${instances.credential.id}`; - urls.jsonCredential = `${urls.projectScope}/credential-stores/${instances.credentials[2].credentialStoreId}/credentials/${instances.credentials[2].id}`; + urls.jsonCredential = `${urls.projectScope}/credential-stores/${instances.credentials[3].credentialStoreId}/credentials/${instances.credentials[3].id}`; urls.addBrokeredCredentialSources = `${urls.target}/add-brokered-credential-sources`; getCredentialLibraryCount = () => this.server.schema.credentialLibraries.all().models.length; @@ -191,14 +191,14 @@ module('Acceptance | targets | brokered credential sources', function (hooks) { }, }); - const jsonCredential = instances.credentials[2]; + const jsonCredential = instances.credentials[3]; instances.target.update({ brokeredCredentialSourceIds: [...randomlySelectedCredentials], }); await visit(urls.brokeredCredentialSources); assert.false(featuresService.isEnabled('json-credentials')); - assert.dom(commonSelectors.TABLE_ROW(3)).includesText(jsonCredential.name); + assert.dom(commonSelectors.TABLE_ROW(4)).includesText(jsonCredential.name); assert.dom(commonSelectors.HREF(urls.jsonCredential)).doesNotExist(); }); diff --git a/ui/admin/tests/integration/components/form/credential/username-password-domain-test.js b/ui/admin/tests/integration/components/form/credential/username-password-domain-test.js new file mode 100644 index 000000000..84274abc8 --- /dev/null +++ b/ui/admin/tests/integration/components/form/credential/username-password-domain-test.js @@ -0,0 +1,232 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'admin/tests/helpers'; +import { click, fillIn, render, blur } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupIntl } from 'ember-intl/test-support'; +import sinon from 'sinon'; + +module( + 'Integration | Component | form/credential/username-password-domain', + function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks, 'en-us'); + + test('it renders', async function (assert) { + this.model = {}; + this.submit = () => { }; + this.cancel = () => { }; + await render( + hbs``, + ); + + assert.dom('[name=domain]').exists(); + assert.dom('[name=username]').exists(); + assert.dom('[name=password]').doesNotExist(); + assert.dom('[name=type]').doesNotExist(); + assert.dom('.info-field').exists(); + assert.dom('[name=name]').exists(); + assert.dom('[name=description]').exists(); + }); + + test('it splits the username and domain when the value contains delimiters such as `@` or `\\` on `onBlur`', async function (assert) { + this.model = { username: '', domain: '', authorized_actions: ['update'] }; + this.submit = () => { }; + this.cancel = () => { }; + await render(hbs` + `); + + await click('button'); + await fillIn('[name=username]', 'username@domain.com'); + await blur('[name=username]'); + + assert.strictEqual(this.model.username, 'username'); + assert.strictEqual(this.model.domain, 'domain.com'); + + // Test with a different delimiter `\` + await fillIn('[name=username]', `username\\domain.com`); + await blur('[name=username]'); + + assert.strictEqual(this.model.username, 'username'); + assert.strictEqual(this.model.domain, 'domain.com'); + }); + + test('it splits the username and domain when the value contains delimiters such as `@` or `\\` when submit is clicked', async function (assert) { + this.model = { username: '', domain: '', authorized_actions: ['update'] }; + this.submit = () => { }; + this.cancel = () => { }; + await render(hbs` + `); + + await click('button'); + await fillIn('[name=username]', 'username@domain.com'); + await click('button'); + + assert.strictEqual(this.model.username, 'username'); + assert.strictEqual(this.model.domain, 'domain.com'); + }); + + test('it does not split the username and domain when the value does not contain a delimiter', async function (assert) { + this.model = { username: '', domain: '', authorized_actions: ['update'] }; + this.submit = () => { }; + this.cancel = () => { }; + await render(hbs` + `); + + await click('button'); + + await fillIn('[name=username]', 'username'); + await blur('[name=username]'); + + assert.strictEqual(this.model.username, 'username'); + assert.strictEqual(this.model.domain, ''); + }); + + test('it does not split if the username or domain is empty', async function (assert) { + this.model = { username: '', domain: '', authorized_actions: ['update'] }; + this.submit = () => { }; + this.cancel = () => { }; + await render(hbs` + `); + + await click('button'); + + // Test with username ending with a delimiter + await fillIn('[name=username]', 'username@'); + await blur('[name=username]'); + assert.strictEqual(this.model.username, 'username@'); + assert.strictEqual(this.model.domain, ''); + + // Check for empty username with domain + await fillIn('[name=username]', '@domain.com'); + await blur('[name=username]'); + assert.strictEqual(this.model.username, '@domain.com'); + assert.strictEqual(this.model.domain, ''); + }); + + test('it does not split when the username is empty or contains multiple delimiters', async function (assert) { + this.model = { username: '', domain: '', authorized_actions: ['update'] }; + this.submit = () => { }; + this.cancel = () => { }; + await render(hbs` + `); + + await click('button'); + + // Check for an empty username + await fillIn('[name=username]', ''); + await blur('[name=username]'); + + assert.strictEqual(this.model.username, ''); + assert.strictEqual(this.model.domain, ''); + + // Check for a username containing multiple delimiters + await fillIn('[name=username]', 'hello@world@again'); + await blur('[name=username]'); + + assert.strictEqual(this.model.username, 'hello@world@again'); + assert.strictEqual(this.model.domain, ''); + }); + + test('it does not update the model when the username remains unchanged', async function (assert) { + this.model = { + username: 'username', + domain: 'domain.com', + authorized_actions: ['update'], + }; + this.submit = () => { }; + this.cancel = () => { }; + await render(hbs` + `); + + await click('button'); + + await fillIn('[name=username]', 'username@domain.com'); + await blur('[name=username]'); + + assert.strictEqual(this.model.username, 'username'); + assert.strictEqual(this.model.domain, 'domain.com'); + }); + + test('it allows the username and domain to be updated', async function (assert) { + this.model = { + username: 'username', + domain: 'domain.com', + authorized_actions: ['update'], + }; + this.submit = () => { }; + this.cancel = () => { }; + await render(hbs` + `); + + await click('button'); + + await fillIn('[name=username]', 'newname'); + await blur('[name=username]'); + await fillIn('[name=domain]', 'newdomain.com'); + + assert.strictEqual(this.model.username, 'newname'); + assert.strictEqual(this.model.domain, 'newdomain.com'); + }); + + test('it updates the username and domain when the submit action is triggered', async function (assert) { + this.model = { + username: 'username', + domain: 'domain.com', + authorized_actions: ['update'], + }; + const submitSpy = sinon.spy(); + this.submit = submitSpy; + this.cancel = () => { }; + + await render(hbs` + `); + + await click('button'); + + await fillIn('[name=username]', 'username2@domain2.com'); + await click('[type=submit]'); + + assert.ok(submitSpy.calledOnce); + assert.deepEqual(submitSpy.firstCall.args, []); + assert.strictEqual(this.model.username, 'username2'); + assert.strictEqual(this.model.domain, 'domain2.com'); + }); + }, +);