diff --git a/e2e-tests/admin/pages/credential-stores.js b/e2e-tests/admin/pages/credential-stores.js index 9e56239a4..f11c88a6f 100644 --- a/e2e-tests/admin/pages/credential-stores.js +++ b/e2e-tests/admin/pages/credential-stores.js @@ -147,7 +147,7 @@ export class CredentialStoresPage extends BaseResourcePage { await this.page.getByRole('link', { name: 'New Credential' }).click(); } - await this.page.getByLabel('Name (Optional)').fill(credentialName); + await this.page.getByLabel('Name', { exact: true }).fill(credentialName); await this.page.getByLabel('Description').fill('This is an automated test'); await this.page.getByRole('combobox', { name: 'Type' }).click(); await this.page @@ -207,7 +207,7 @@ export class CredentialStoresPage extends BaseResourcePage { } const credentialName = 'Credential ' + nanoid(); - await this.page.getByLabel('Name (Optional)').fill(credentialName); + await this.page.getByLabel('Name', { exact: true }).fill(credentialName); await this.page.getByLabel('Description').fill('This is an automated test'); await this.page.getByRole('combobox', { name: 'Type' }).click(); await this.page @@ -271,7 +271,7 @@ export class CredentialStoresPage extends BaseResourcePage { } const credentialName = 'Credential ' + nanoid(); - await this.page.getByLabel('Name (Optional)').fill(credentialName); + await this.page.getByLabel('Name', { exact: true }).fill(credentialName); await this.page.getByLabel('Description').fill('This is an automated test'); await this.page.getByRole('combobox', { name: 'Type' }).click(); await this.page diff --git a/e2e-tests/admin/tests/credential-store-static.spec.js b/e2e-tests/admin/tests/credential-store-static.spec.js index 7bbc251a5..0a8e78fce 100644 --- a/e2e-tests/admin/tests/credential-store-static.spec.js +++ b/e2e-tests/admin/tests/credential-store-static.spec.js @@ -191,7 +191,7 @@ test( .getByRole('link', { name: 'Credentials', exact: true }) .click(); await page.getByRole('link', { name: 'New', exact: true }).click(); - await page.getByLabel('Name (Optional)').fill(credentialName); + await page.getByLabel('Name', { exact: true }).fill(credentialName); await page.getByLabel('Description').fill('This is an automated test'); await page.getByRole('combobox', { name: 'Type' }).click(); await page.getByRole('option', { name: 'JSON' }).click(); diff --git a/ui/admin/app/components/form/credential/json/index.hbs b/ui/admin/app/components/form/credential/json/index.hbs index bc97a8ab6..90f11f567 100644 --- a/ui/admin/app/components/form/credential/json/index.hbs +++ b/ui/admin/app/components/form/credential/json/index.hbs @@ -15,7 +15,6 @@ name='name' @value={{@model.name}} @isInvalid={{@model.errors.name}} - @isOptional={{true}} disabled={{form.disabled}} {{on 'input' (set-from-event @model 'name')}} as |F| @@ -37,7 +36,6 @@ name='description' @value={{@model.description}} @isInvalid={{@model.errors.description}} - @isOptional={{true}} disabled={{form.disabled}} as |F| > diff --git a/ui/admin/app/components/form/credential/password/index.hbs b/ui/admin/app/components/form/credential/password/index.hbs index e76257cef..205ffa68a 100644 --- a/ui/admin/app/components/form/credential/password/index.hbs +++ b/ui/admin/app/components/form/credential/password/index.hbs @@ -1,4 +1,108 @@ {{! Copyright (c) HashiCorp, Inc. SPDX-License-Identifier: BUSL-1.1 -}} \ No newline at end of file +}} + + + + + {{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}} + + {{#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}} + + {{#if (can 'save model' @model)}} + + {{/if}} + + \ No newline at end of file diff --git a/ui/admin/app/components/form/credential/select/index.hbs b/ui/admin/app/components/form/credential/select/index.hbs index b04ecad38..76e536a5e 100644 --- a/ui/admin/app/components/form/credential/select/index.hbs +++ b/ui/admin/app/components/form/credential/select/index.hbs @@ -5,6 +5,7 @@ diff --git a/ui/admin/app/components/form/credential/username_password/index.hbs b/ui/admin/app/components/form/credential/username_password/index.hbs index ef1ff305d..42812771e 100644 --- a/ui/admin/app/components/form/credential/username_password/index.hbs +++ b/ui/admin/app/components/form/credential/username_password/index.hbs @@ -15,7 +15,6 @@ name='name' @value={{@model.name}} @isInvalid={{@model.errors.name}} - @isOptional={{true}} disabled={{form.disabled}} {{on 'input' (set-from-event @model 'name')}} as |F| @@ -37,7 +36,6 @@ name='description' @value={{@model.description}} @isInvalid={{@model.errors.description}} - @isOptional={{true}} disabled={{form.disabled}} as |F| > diff --git a/ui/admin/app/components/form/credential/username_password_domain/index.hbs b/ui/admin/app/components/form/credential/username_password_domain/index.hbs index 7e53a17c6..9089e3ad7 100644 --- a/ui/admin/app/components/form/credential/username_password_domain/index.hbs +++ b/ui/admin/app/components/form/credential/username_password_domain/index.hbs @@ -15,7 +15,6 @@ name='name' @value={{@model.name}} @isInvalid={{@model.errors.name}} - @isOptional={{true}} disabled={{form.disabled}} {{on 'input' (set-from-event @model 'name')}} as |F| @@ -37,7 +36,6 @@ name='description' @value={{@model.description}} @isInvalid={{@model.errors.description}} - @isOptional={{true}} disabled={{form.disabled}} as |F| > 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 0c3c9e0c8..3fce76109 100644 --- a/ui/admin/tests/acceptance/credential-store/credentials/create-test.js +++ b/ui/admin/tests/acceptance/credential-store/credentials/create-test.js @@ -11,6 +11,13 @@ 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, + TYPE_CREDENTIAL_JSON, + TYPE_CREDENTIAL_PASSWORD, +} from 'api/models/credential'; module( 'Acceptance | credential-stores | credentials | create', @@ -23,6 +30,7 @@ module( let getUsernameKeyPairCredentialCount; let getUsernamePasswordDomainCredentialCount; let getJsonCredentialCount; + let getPasswordCredentialCount; let featuresService; const instances = { @@ -68,19 +76,27 @@ module( }; 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; + return this.server.schema.credentials.where({ + type: TYPE_CREDENTIAL_JSON, + }).length; }; getUsernamePasswordDomainCredentialCount = () => { return this.server.schema.credentials.where({ - type: 'username_password_domain', + type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + }).length; + }; + getPasswordCredentialCount = () => { + return this.server.schema.credentials.where({ + type: TYPE_CREDENTIAL_PASSWORD, }).length; }; }); @@ -253,6 +269,36 @@ module( assert.strictEqual(getJsonCredentialCount(), jsonCredentialCount + 1); }); + test('users can create a new password credential', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-11-06 + enabled: false, + }, + }, + }); + + const credentialsCount = getCredentialsCount(); + const passwordCredentialCount = getPasswordCredentialCount(); + await visit(urls.credentials); + + await click(commonSelectors.HREF(urls.newCredential)); + await fillIn( + commonSelectors.FIELD_NAME, + commonSelectors.FIELD_NAME_VALUE, + ); + await click(selectors.TYPE_SELECT); + await click(selectors.FIELD_TYPE_PASSWORD); + await click(commonSelectors.SAVE_BTN); + + assert.strictEqual(getCredentialsCount(), credentialsCount + 1); + assert.strictEqual( + getPasswordCredentialCount(), + passwordCredentialCount + 1, + ); + }); + test('users can cancel create new username & password credential', async function (assert) { setRunOptions({ rules: { @@ -391,6 +437,32 @@ module( assert.dom(selectors.EDITOR).includesText('{}'); }); + test('users can cancel creation of new password credential', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-11-06 + enabled: false, + }, + }, + }); + + 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.TYPE_SELECT); + await click(selectors.FIELD_TYPE_PASSWORD); + await click(commonSelectors.CANCEL_BTN); + + assert.strictEqual(currentURL(), urls.credentials); + assert.strictEqual(getCredentialsCount(), credentialsCount); + }); + test('users cannot navigate to new credential route without proper authorization', async function (assert) { setRunOptions({ rules: { @@ -598,6 +670,49 @@ module( assert.dom(commonSelectors.ALERT_TOAST_BODY).hasText(errorMessage); }); + test('saving a new password credential with invalid fields displays error messages', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-11-06 + enabled: false, + }, + }, + }); + + const errorMessage = 'Error in provided request.'; + const errorDescription = + 'Field required for creating a password 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.password', + description: errorDescription, + }, + ], + }, + }, + ); + }); + + await click(commonSelectors.HREF(urls.newCredential)); + await click(selectors.TYPE_SELECT); + await click(selectors.FIELD_TYPE_PASSWORD); + await click(commonSelectors.SAVE_BTN); + + assert.dom(commonSelectors.ALERT_TOAST_BODY).hasText(errorMessage); + assert.dom(selectors.FIELD_PASSWORD_ERROR).hasText(errorDescription); + }); + test('users cannot create a new credential without proper authorization', 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 edb7369a4..cb500edfe 100644 --- a/ui/admin/tests/acceptance/credential-store/credentials/delete-test.js +++ b/ui/admin/tests/acceptance/credential-store/credentials/delete-test.js @@ -15,6 +15,8 @@ import { TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, TYPE_CREDENTIAL_SSH_PRIVATE_KEY, TYPE_CREDENTIAL_USERNAME_PASSWORD, + TYPE_CREDENTIAL_JSON, + TYPE_CREDENTIAL_PASSWORD, } from 'api/models/credential'; module( @@ -27,6 +29,7 @@ module( let getUsernameKeyPairCredentialCount; let getJSONCredentialCount; let getUsernamePasswordDomainCredentialCount; + let getPasswordCredentialCount; const mockResponseMessage = 'Oops.'; const mockResponse = () => { @@ -57,6 +60,7 @@ module( usernameKeyPairCredential: null, jsonCredential: null, usernamePasswordDomainCredential: null, + passwordCredential: null, }; hooks.beforeEach(async function () { @@ -94,7 +98,12 @@ module( instances.jsonCredential = this.server.create('credential', { scope: instances.scopes.project, credentialStore: instances.staticCredentialStore, - type: 'json', + type: TYPE_CREDENTIAL_JSON, + }); + instances.passwordCredential = this.server.create('credential', { + scope: instances.scopes.project, + credentialStore: instances.staticCredentialStore, + type: TYPE_CREDENTIAL_PASSWORD, }); // Generate route URLs for resources urls.projectScope = `/scopes/${instances.scopes.project.id}`; @@ -105,6 +114,7 @@ module( urls.usernameKeyPairCredential = `${urls.credentials}/${instances.usernameKeyPairCredential.id}`; urls.usernamePasswordDomainCredential = `${urls.credentials}/${instances.usernamePasswordDomainCredential.id}`; urls.jsonCredential = `${urls.credentials}/${instances.jsonCredential.id}`; + urls.passwordCredential = `${urls.credentials}/${instances.passwordCredential.id}`; // Generate resource counter getUsernamePasswordCredentialCount = () => { return this.server.schema.credentials.where({ @@ -124,6 +134,11 @@ module( type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, }).length; }; + getPasswordCredentialCount = () => { + return this.server.schema.credentials.where({ + type: TYPE_CREDENTIAL_PASSWORD, + }).length; + }; }); test('can delete username & password credential', async function (assert) { @@ -219,6 +234,29 @@ module( ); }); + test('can delete password credential', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-11-06 + enabled: false, + }, + }, + }); + + const passwordCredentialCount = getPasswordCredentialCount(); + await visit(urls.passwordCredential); + + await click(selectors.MANAGE_DROPDOWN); + await click(selectors.MANAGE_DROPDOWN_DELETE); + + assert.strictEqual(currentURL(), urls.credentials); + assert.strictEqual( + getPasswordCredentialCount(), + passwordCredentialCount - 1, + ); + }); + test('cannot delete a username & password credential without proper authorization', async function (assert) { const usernamePasswordCredentialCount = getUsernamePasswordCredentialCount(); @@ -283,6 +321,30 @@ module( ); }); + test('cannot delete a password credential without proper authorization', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-11-06 + enabled: false, + }, + }, + }); + + const passwordCredentialCount = getPasswordCredentialCount(); + instances.passwordCredential.authorized_actions = + instances.passwordCredential.authorized_actions.filter( + (item) => item !== 'delete', + ); + await visit(urls.credentials); + + await click(commonSelectors.HREF(urls.passwordCredential)); + + assert.strictEqual(currentURL(), urls.passwordCredential); + assert.dom(selectors.MANAGE_DROPDOWN).doesNotExist(); + assert.strictEqual(getPasswordCredentialCount(), passwordCredentialCount); + }); + test('can accept delete username & password credential via dialog', async function (assert) { setRunOptions({ rules: { @@ -359,6 +421,33 @@ module( assert.strictEqual(getJSONCredentialCount(), jsonCredentialCount - 1); }); + test('can accept delete password credential via dialog', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-11-06 + enabled: false, + }, + }, + }); + + const confirmService = this.owner.lookup('service:confirm'); + confirmService.enabled = true; + const passwordCredentialCount = getPasswordCredentialCount(); + await visit(urls.credentials); + + await click(commonSelectors.HREF(urls.passwordCredential)); + await click(selectors.MANAGE_DROPDOWN); + await click(selectors.MANAGE_DROPDOWN_DELETE); + await click(commonSelectors.MODAL_WARNING_CONFIRM_BTN); + + assert.strictEqual(currentURL(), urls.credentials); + assert.strictEqual( + getPasswordCredentialCount(), + passwordCredentialCount - 1, + ); + }); + test('can cancel delete username & password credential via dialog', async function (assert) { setRunOptions({ rules: { @@ -463,6 +552,30 @@ module( assert.strictEqual(getJSONCredentialCount(), jsonCredentialCount); }); + test('can cancel delete password credential via dialog', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-11-06 + enabled: false, + }, + }, + }); + + const confirmService = this.owner.lookup('service:confirm'); + confirmService.enabled = true; + const passwordCredentialCount = getPasswordCredentialCount(); + await visit(urls.credentials); + + await click(commonSelectors.HREF(urls.passwordCredential)); + await click(selectors.MANAGE_DROPDOWN); + await click(selectors.MANAGE_DROPDOWN_DELETE); + await click(commonSelectors.MODAL_WARNING_CANCEL_BTN); + + assert.strictEqual(currentURL(), urls.passwordCredential); + assert.strictEqual(getPasswordCredentialCount(), passwordCredentialCount); + }); + test('deleting a username & password credential which errors displays error message', async function (assert) { setRunOptions({ rules: { @@ -548,5 +661,24 @@ module( assert.dom(commonSelectors.ALERT_TOAST_BODY).hasText('Oops.'); }); + + test('deleting a password credential which errors displays error message', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-11-06 + enabled: false, + }, + }, + }); + + this.server.del('/credentials/:id', mockResponse); + await visit(urls.passwordCredential); + + await click(selectors.MANAGE_DROPDOWN); + await click(selectors.MANAGE_DROPDOWN_DELETE); + + assert.dom(commonSelectors.ALERT_TOAST_BODY).hasText(mockResponseMessage); + }); }, ); 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 99cb31c57..ac0746ee3 100644 --- a/ui/admin/tests/acceptance/credential-store/credentials/read-test.js +++ b/ui/admin/tests/acceptance/credential-store/credentials/read-test.js @@ -9,6 +9,13 @@ import { setupApplicationTest } from 'admin/tests/helpers'; import { setupSqlite } from 'api/test-support/helpers/sqlite'; 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, + TYPE_CREDENTIAL_JSON, + TYPE_CREDENTIAL_PASSWORD, +} from 'api/models/credential'; module('Acceptance | credential-stores | credentials | read', function (hooks) { setupApplicationTest(hooks); @@ -24,6 +31,7 @@ module('Acceptance | credential-stores | credentials | read', function (hooks) { usernameKeyPairCredential: null, jsonCredential: null, usernamePasswordDomainCredential: null, + passwordCredential: null, }; const urls = { @@ -36,6 +44,7 @@ module('Acceptance | credential-stores | credentials | read', function (hooks) { jsonCredential: null, unknownCredential: null, usernamePasswordDomainCredential: null, + passwordCredential: null, }; hooks.beforeEach(async function () { @@ -55,26 +64,31 @@ module('Acceptance | credential-stores | credentials | read', function (hooks) { 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.jsonCredential = this.server.create('credential', { scope: instances.scopes.project, credentialStore: instances.staticCredentialStore, - type: 'json', + type: TYPE_CREDENTIAL_JSON, }); instances.usernamePasswordDomainCredential = this.server.create( 'credential', { scope: instances.scopes.project, credentialStore: instances.staticCredentialStore, - type: 'username_password_domain', + type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, }, ); + instances.passwordCredential = this.server.create('credential', { + scope: instances.scopes.project, + credentialStore: instances.staticCredentialStore, + type: TYPE_CREDENTIAL_PASSWORD, + }); // Generate route URLs for resources urls.projectScope = `/scopes/${instances.scopes.project.id}`; @@ -85,6 +99,7 @@ module('Acceptance | credential-stores | credentials | read', function (hooks) { urls.usernameKeyPairCredential = `${urls.credentials}/${instances.usernameKeyPairCredential.id}`; urls.jsonCredential = `${urls.credentials}/${instances.jsonCredential.id}`; urls.usernamePasswordDomainCredential = `${urls.credentials}/${instances.usernamePasswordDomainCredential.id}`; + urls.passwordCredential = `${urls.credentials}/${instances.passwordCredential.id}`; urls.unknownCredential = `${urls.credentials}/foo`; }); @@ -169,6 +184,26 @@ module('Acceptance | credential-stores | credentials | read', function (hooks) { assert.strictEqual(currentURL(), urls.usernamePasswordDomainCredential); }); + test('visiting password credential', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-11-06 + enabled: false, + }, + }, + }); + + await visit(urls.staticCredentialStore); + await click(commonSelectors.HREF(urls.credentials)); + + assert.strictEqual(currentURL(), urls.credentials); + + await click(commonSelectors.HREF(urls.passwordCredential)); + + assert.strictEqual(currentURL(), urls.passwordCredential); + }); + test('cannot navigate to a username & password credential form without proper authorization', async function (assert) { setRunOptions({ rules: { @@ -266,6 +301,30 @@ module('Acceptance | credential-stores | credentials | read', function (hooks) { .doesNotExist(); }); + test('cannot navigate to a password credential form without proper authorization', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-11-06 + enabled: false, + }, + }, + }); + + instances.passwordCredential.authorized_actions = + instances.passwordCredential.authorized_actions.filter( + (item) => item != 'read', + ); + await visit(urls.credentials); + + assert + .dom(commonSelectors.TABLE_RESOURCE_LINK(urls.usernamePasswordCredential)) + .isVisible(); + assert + .dom(commonSelectors.TABLE_RESOURCE_LINK(urls.passwordCredential)) + .doesNotExist(); + }); + test('visiting an unknown credential displays 404 message', 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 3804a31aa..591f22de3 100644 --- a/ui/admin/tests/acceptance/credential-store/credentials/selectors.js +++ b/ui/admin/tests/acceptance/credential-store/credentials/selectors.js @@ -6,10 +6,11 @@ export const FIELD_TYPE_SSH = '[data-option-index="1"]'; export const FIELD_TYPE_USERNAME_PASSWORD = '[data-option-index="0"]'; export const FIELD_TYPE_USERNAME_PASSWORD_DOMAIN = '[data-option-index="2"]'; +export const FIELD_TYPE_JSON = '[data-option-index="3"]'; +export const FIELD_TYPE_PASSWORD = '[data-option-index="4"]'; export const FIELD_DOMAIN = '[name=domain]'; export const FIELD_PASSWORD = '[name=password]'; export const FIELD_USERNAME = '[name=username]'; -export const FIELD_TYPE_JSON = '[data-option-index="3"]'; export const FIELD_DOMAIN_VALUE = 'g.com'; export const FIELD_USERNAME_VALUE = 'username123'; export const FIELD_PASSWORD_VALUE = 'password123'; 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 5fe15719a..c869df66b 100644 --- a/ui/admin/tests/acceptance/credential-store/credentials/update-test.js +++ b/ui/admin/tests/acceptance/credential-store/credentials/update-test.js @@ -18,6 +18,13 @@ 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, + TYPE_CREDENTIAL_JSON, + TYPE_CREDENTIAL_PASSWORD, +} from 'api/models/credential'; module( 'Acceptance | credential-stores | credentials | update', @@ -35,6 +42,7 @@ module( usernameKeyPairCredential: null, jsonCredential: null, usernamePasswordDomainCredential: null, + passwordCredential: null, }; const urls = { @@ -46,6 +54,7 @@ module( usernameKeyPairCredential: null, jsonCredential: null, usernamePasswordDomainCredential: null, + passwordCredential: null, }; const mockResponseMessage = 'Error in provided request.'; @@ -87,26 +96,31 @@ 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.jsonCredential = this.server.create('credential', { scope: instances.scopes.project, credentialStore: instances.staticCredentialStore, - type: 'json', + type: TYPE_CREDENTIAL_JSON, }); instances.usernamePasswordDomainCredential = this.server.create( 'credential', { scope: instances.scopes.project, credentialStore: instances.staticCredentialStore, - type: 'username_password_domain', + type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, }, ); + instances.passwordCredential = this.server.create('credential', { + scope: instances.scopes.project, + credentialStore: instances.staticCredentialStore, + type: TYPE_CREDENTIAL_PASSWORD, + }); // Generate route URLs for resources urls.projectScope = `/scopes/${instances.scopes.project.id}`; @@ -117,6 +131,7 @@ module( urls.usernameKeyPairCredential = `${urls.credentials}/${instances.usernameKeyPairCredential.id}`; urls.jsonCredential = `${urls.credentials}/${instances.jsonCredential.id}`; urls.usernamePasswordDomainCredential = `${urls.credentials}/${instances.usernamePasswordDomainCredential.id}`; + urls.passwordCredential = `${urls.credentials}/${instances.passwordCredential.id}`; }); test('can save changes to existing username & password credential', async function (assert) { @@ -231,6 +246,37 @@ module( assert.strictEqual(credential.attributes.domain, 'g.com'); }); + test('can save changes to existing password credential', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-11-06 + enabled: false, + }, + }, + }); + + assert.notEqual( + instances.passwordCredential.name, + commonSelectors.FIELD_NAME_VALUE, + ); + await visit(urls.passwordCredential); + + await click(commonSelectors.EDIT_BTN); + await fillIn( + commonSelectors.FIELD_NAME, + commonSelectors.FIELD_NAME_VALUE, + ); + await click(commonSelectors.SAVE_BTN); + + assert.strictEqual(currentURL(), urls.passwordCredential); + assert.strictEqual( + this.server.schema.credentials.where({ type: TYPE_CREDENTIAL_PASSWORD }) + .models[0].name, + commonSelectors.FIELD_NAME_VALUE, + ); + }); + 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( @@ -262,6 +308,27 @@ module( assert.dom(commonSelectors.EDIT_BTN).doesNotExist(); }); + test('cannot make changes to an existing password credential without proper authorization', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-11-06 + enabled: false, + }, + }, + }); + + instances.passwordCredential.authorized_actions = + instances.passwordCredential.authorized_actions.filter( + (item) => item !== 'update', + ); + await visit(urls.credentials); + + await click(commonSelectors.HREF(urls.passwordCredential)); + + assert.dom(commonSelectors.EDIT_BTN).doesNotExist(); + }); + test('can cancel changes to existing username & password credential', async function (assert) { setRunOptions({ rules: { @@ -336,6 +403,33 @@ module( .hasValue(instances.jsonCredential.name); }); + test('can cancel changes to existing password credential', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-11-06 + enabled: false, + }, + }, + }); + + await visit(urls.passwordCredential); + await click(commonSelectors.EDIT_BTN, 'Activate edit mode'); + await fillIn( + commonSelectors.FIELD_NAME, + commonSelectors.FIELD_NAME_VALUE, + ); + await click(commonSelectors.CANCEL_BTN); + + assert.notEqual( + instances.passwordCredential.name, + commonSelectors.FIELD_NAME_VALUE, + ); + assert + .dom(commonSelectors.FIELD_NAME) + .hasValue(instances.passwordCredential.name); + }); + test('saving an existing username & password credential with invalid fields displays error message', async function (assert) { setRunOptions({ rules: { @@ -405,6 +499,32 @@ module( .hasText(mockResponseDescription); }); + test('saving an existing password credential with invalid fields displays error message', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-11-06 + enabled: false, + }, + }, + }); + + this.server.patch('/credentials/:id', mockResponse); + await visit(urls.passwordCredential); + + await click(commonSelectors.EDIT_BTN, 'Activate edit mode'); + await fillIn( + commonSelectors.FIELD_NAME, + commonSelectors.FIELD_NAME_VALUE, + ); + await click(commonSelectors.SAVE_BTN); + + assert.dom(commonSelectors.ALERT_TOAST_BODY).hasText(mockResponseMessage); + assert + .dom(commonSelectors.FIELD_NAME_ERROR) + .hasText(mockResponseDescription); + }); + test('can discard unsaved username & password credential changes via dialog', async function (assert) { setRunOptions({ rules: { @@ -530,6 +650,48 @@ module( } }); + test('can discard unsaved password credential changes via dialog', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-11-06 + enabled: false, + }, + }, + }); + + assert.expect(5); + const confirmService = this.owner.lookup('service:confirm'); + confirmService.enabled = true; + assert.notEqual( + instances.passwordCredential.name, + commonSelectors.FIELD_NAME_VALUE, + ); + await visit(urls.passwordCredential); + + await click(commonSelectors.EDIT_BTN, 'Activate edit mode'); + await fillIn( + commonSelectors.FIELD_NAME, + commonSelectors.FIELD_NAME_VALUE, + ); + assert.strictEqual(currentURL(), urls.passwordCredential); + try { + await visit(urls.credentials); + } catch (e) { + assert.dom(commonSelectors.MODAL_WARNING).isVisible(); + + await click(commonSelectors.MODAL_WARNING_CONFIRM_BTN, 'Click Discard'); + + assert.strictEqual(currentURL(), urls.credentials); + assert.notEqual( + this.server.schema.credentials.where({ + type: TYPE_CREDENTIAL_PASSWORD, + }).models[0].name, + commonSelectors.FIELD_NAME_VALUE, + ); + } + }); + test('can cancel discard unsaved username & password credential changes via dialog', async function (assert) { setRunOptions({ rules: { @@ -679,6 +841,54 @@ module( } }); + test('can cancel discard unsaved password credential changes via dialog', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-11-06 + enabled: false, + }, + }, + }); + + assert.expect(6); + const confirmService = this.owner.lookup('service:confirm'); + confirmService.enabled = true; + assert.notEqual( + instances.passwordCredential.name, + commonSelectors.FIELD_NAME_VALUE, + ); + await visit(urls.passwordCredential); + + await click(commonSelectors.EDIT_BTN); + const credentialName = find(commonSelectors.FIELD_NAME).value; + await fillIn( + commonSelectors.FIELD_NAME, + commonSelectors.FIELD_NAME_VALUE, + ); + + assert.strictEqual(currentURL(), urls.passwordCredential); + + try { + await visit(urls.credentials); + } catch (e) { + assert.dom(commonSelectors.MODAL_WARNING).isVisible(); + + await click(commonSelectors.MODAL_WARNING_CANCEL_BTN, 'Click Cancel'); + + assert.strictEqual(currentURL(), urls.passwordCredential); + assert + .dom(commonSelectors.FIELD_NAME) + .hasValue(commonSelectors.FIELD_NAME_VALUE); + assert.strictEqual( + this.server.schema.credentials.where({ + type: TYPE_CREDENTIAL_PASSWORD, + }).models[0].name, + credentialName, + ); + } + }); + test('password field renders in edit mode only for a username & password credential', async function (assert) { setRunOptions({ rules: { @@ -699,6 +909,26 @@ module( assert.dom(commonSelectors.FIELD_PASSWORD).isVisible(); }); + test('password field renders in edit mode only for a password credential', async function (assert) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-11-06 + enabled: false, + }, + }, + }); + + await visit(urls.passwordCredential); + + assert.dom(commonSelectors.FIELD_PASSWORD).doesNotExist(); + + await click(commonSelectors.EDIT_BTN); + + assert.strictEqual(currentURL(), urls.passwordCredential); + assert.dom(commonSelectors.FIELD_PASSWORD).isVisible(); + }); + test('private_key and private_key_passphrase fields render in edit mode only for a username & key pair credential', async function (assert) { setRunOptions({ rules: { 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 1d16a3bb7..22b9c36ff 100644 --- a/ui/admin/tests/acceptance/targets/brokered-credential-sources-test.js +++ b/ui/admin/tests/acceptance/targets/brokered-credential-sources-test.js @@ -16,6 +16,7 @@ import { TYPE_CREDENTIAL_SSH_PRIVATE_KEY, TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, TYPE_CREDENTIAL_JSON, + TYPE_CREDENTIAL_PASSWORD, } from 'api/models/credential'; import { TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, @@ -56,6 +57,7 @@ module('Acceptance | targets | brokered credential sources', function (hooks) { credentialLibrary: null, credential: null, jsonCredential: null, + passwordCredential: null, addBrokeredCredentialSourcesForTCPTarget: null, brokeredCredentialSourcesForTCPTarget: null, addBrokeredCredentialSourcesForRDPTarget: null, @@ -80,7 +82,7 @@ module('Acceptance | targets | brokered credential sources', function (hooks) { type: 'static', scope: instances.scopes.project, }); - instances.credentials = this.server.createList('credential', 4, { + instances.credentials = this.server.createList('credential', 5, { scope: instances.scopes.project, credentialStore: instances.staticCredentialStore, }); @@ -132,6 +134,7 @@ module('Acceptance | targets | brokered credential sources', function (hooks) { 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[3].credentialStoreId}/credentials/${instances.credentials[3].id}`; + urls.passwordCredential = `${urls.projectScope}/credential-stores/${instances.credentials[4].credentialStoreId}/credentials/${instances.credentials[4].id}`; urls.addBrokeredCredentialSourcesForTCPTarget = `${urls.tcpTarget}/add-brokered-credential-sources`; urls.addBrokeredCredentialSourcesForRDPTarget = `${urls.rdpTarget}/add-brokered-credential-sources`; getCredentialLibraryCount = () => @@ -210,6 +213,18 @@ module('Acceptance | targets | brokered credential sources', function (hooks) { link: 'jsonCredential', expectedUrl: 'jsonCredential', }, + 'password credential type for TCP target': { + route: 'brokeredCredentialSourcesForTCPTarget', + targetName: 'tcpTarget', + link: 'passwordCredential', + expectedUrl: 'passwordCredential', + }, + 'password credential type for RDP target': { + route: 'brokeredCredentialSourcesForRDPTarget', + targetName: 'rdpTarget', + link: 'passwordCredential', + expectedUrl: 'passwordCredential', + }, }, async function (assert, input) { setRunOptions({ @@ -427,6 +442,11 @@ module('Acceptance | targets | brokered credential sources', function (hooks) { action: commonSelectors.SAVE_BTN, expectedCount: 1, }, + 'save password credential': { + credentialSources: [TYPE_CREDENTIAL_PASSWORD], + action: commonSelectors.SAVE_BTN, + expectedCount: 1, + }, 'save credentials and credential-libraries': { credentialSources: [ TYPE_CREDENTIAL_USERNAME_PASSWORD, @@ -530,6 +550,11 @@ module('Acceptance | targets | brokered credential sources', function (hooks) { action: commonSelectors.SAVE_BTN, expectedCount: 1, }, + 'save password credential': { + credentialSources: [TYPE_CREDENTIAL_PASSWORD], + action: commonSelectors.SAVE_BTN, + expectedCount: 1, + }, 'save credentials and credential-libraries': { credentialSources: [ TYPE_CREDENTIAL_USERNAME_PASSWORD, @@ -572,7 +597,11 @@ module('Acceptance | targets | brokered credential sources', function (hooks) { action: commonSelectors.CANCEL_BTN, expectedCount: 0, }, - + 'cancel password credential': { + credentialSources: [TYPE_CREDENTIAL_PASSWORD], + action: commonSelectors.CANCEL_BTN, + expectedCount: 0, + }, 'cancel credentials and credential-libraries': { credentialSources: [ TYPE_CREDENTIAL_USERNAME_PASSWORD,