Add password credential form (#3054)

* feat: 🎸 add password credential form

* test: 💍 add a11y ignores

* test: 💍 fix e2e tests

* test: 💍 add password cred type to targets brokering tests

* chore: 🤖 remove comment
This commit is contained in:
Lisbet Alvarez authored and Lisbet Alvarez committed 2025-11-18 11:20:49 -08:00
1 parent bbb5808875
commit 6bb237e78c
14 files changed
+693 -30

No files matched your search

+3 -3
View File
@@ -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
@@ -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();
@@ -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|
>
@@ -1,4 +1,108 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
}}
<Rose::Form
@onSubmit={{@submit}}
@cancel={{@cancel}}
@disabled={{@model.isSaving}}
@showEditToggle={{if @model.isNew false true}}
as |form|
>
<Hds::Form::TextInput::Field
name='name'
@value={{@model.name}}
@isInvalid={{@model.errors.name}}
disabled={{form.disabled}}
{{on 'input' (set-from-event @model 'name')}}
as |F|
>
<F.Label>{{t 'form.name.label'}}</F.Label>
<F.HelperText>{{t 'form.name.help'}}</F.HelperText>
{{#if @model.errors.name}}
<F.Error as |E|>
{{#each @model.errors.name as |error|}}
<E.Message data-test-error-message-name>
{{error.message}}
</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
<Hds::Form::Textarea::Field
name='description'
@value={{@model.description}}
@isInvalid={{@model.errors.description}}
disabled={{form.disabled}}
as |F|
>
<F.Label>{{t 'form.description.label'}}</F.Label>
<F.HelperText>{{t 'form.description.help'}}</F.HelperText>
{{#if @model.errors.description}}
<F.Error as |E|>
{{#each @model.errors.description as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::Textarea::Field>
{{#if @model.isNew}}
<Form::Credential::Select
@model={{@model}}
@types={{@types}}
@changeType={{@changeType}}
/>
{{else}}
<InfoField
@value={{t (concat 'resources.credential.types.' @model.type)}}
name='type'
readonly={{true}}
as |F|
>
<F.Label>{{t 'form.type.label'}}</F.Label>
<F.HelperText>{{t (concat 'resources.credential.help.' @model.type)}}
</F.HelperText>
</InfoField>
{{/if}}
{{#if (or @model.isNew form.isEditable)}}
<Hds::Form::TextInput::Field
name='password'
@type='password'
@value={{@model.password}}
@isInvalid={{@model.errors.password}}
@isRequired={{true}}
disabled={{form.disabled}}
autocomplete='new-password'
{{on 'input' (set-from-event @model 'password')}}
as |F|
>
<F.Label>{{t 'resources.credential.form.password.label'}}</F.Label>
<F.HelperText>{{t
'resources.credential.form.password.help'
}}</F.HelperText>
{{#if @model.errors.password}}
<F.Error as |E|>
{{#each @model.errors.password as |error|}}
<E.Message data-test-error-message-password>
{{error.message}}
</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
{{/if}}
{{#if (can 'save model' @model)}}
<form.actions
@enableEditText={{t 'actions.edit-form'}}
@submitText={{t 'actions.save'}}
@cancelText={{t 'actions.cancel'}}
/>
{{/if}}
</Rose::Form>
@@ -5,6 +5,7 @@
<Hds::Form::SuperSelect::Single::Field
name={{t 'form.type.label'}}
@isRequired={{true}}
@onChange={{@changeType}}
@selected={{@model.type}}
@options={{@types}}
@@ -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|
>
@@ -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|
>
@@ -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|
>
@@ -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: {
@@ -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);
});
},
);
@@ -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: {
@@ -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';
@@ -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: {
@@ -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,