feat: 🎸 add support for upd credential

This commit is contained in:
Dharini Jeeva authored and Dharini committed 2025-09-03 15:59:29 -07:00
1 parent 25f015b179
commit e1696e9a4d
34 files changed
+1328 -153

No files matched your search

@@ -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;
}
+15 -2
View File
@@ -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',
],
},
};
+2
View File
@@ -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,
]);
+25 -4
View File
@@ -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 {};
}
},
});
@@ -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);
});
});
@@ -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.
@@ -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.
@@ -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,
@@ -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,
@@ -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);
}
}
},
);
+56
View File
@@ -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<string>} 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;
}
@@ -115,37 +115,53 @@
{{/if}}
</Hds::Form::TextInput::Field>
<Hds::Form::Select::Field
@isOptional={{true}}
@width='100%'
name='credential_type'
disabled={{or form.disabled (not @model.isNew)}}
{{on 'input' this.selectCredentialType}}
as |F|
>
<F.Label>
{{t 'resources.credential-library.form.credential_type.label'}}
</F.Label>
<F.HelperText>
{{t 'resources.credential-library.form.credential_type.help'}}
</F.HelperText>
<F.Options>
<option value='' />
{{#if @model.isNew}}
<Hds::Form::Select::Field
@isOptional={{true}}
@width='100%'
name='credential_type'
disabled={{form.disabled}}
{{on 'input' this.selectCredentialType}}
as |F|
>
<F.Label>
{{t 'resources.credential-library.form.credential_type.label'}}
</F.Label>
<F.HelperText>
{{t 'resources.credential-library.form.credential_type.help'}}
</F.HelperText>
<F.Options>
<option value='' />
{{#each this.credentialTypes as |type|}}
<option value={{type}} selected={{eq type @model.credential_type}}>
{{t (concat 'resources.credential-library.titles.' type)}}
</option>
{{/each}}
</F.Options>
{{#if @model.errors.credential_type}}
<F.Error as |E|>
{{#each @model.errors.credential_type as |error|}}
<E.Message>{{error.message}}</E.Message>
{{#each this.credentialTypes as |type|}}
<option value={{type}} selected={{eq type @model.credential_type}}>
{{t (concat 'resources.credential-library.titles.' type)}}
</option>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::Select::Field>
</F.Options>
{{#if @model.errors.credential_type}}
<F.Error as |E|>
{{#each @model.errors.credential_type as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::Select::Field>
{{else}}
<InfoField
@value={{t
(concat 'resources.credential-library.titles.' @model.credential_type)
}}
as |F|
>
<F.Label>{{t
'resources.credential-library.form.credential_type.label'
}}</F.Label>
<F.HelperText>
{{t 'resources.credential-library.form.credential_type.description'}}
</F.HelperText>
</InfoField>
{{/if}}
{{#if @model.credential_type}}
<Form::Field::ListWrapper
@@ -53,26 +53,11 @@
</Hds::Form::Textarea::Field>
{{#if @model.isNew}}
<Hds::Form::RadioCard::Group @name={{t 'form.type.label'}} as |G|>
<G.Legend>{{t 'form.type.label'}}</G.Legend>
{{#each @types as |credentialType|}}
<G.RadioCard
@value={{credentialType}}
@checked={{eq credentialType @model.type}}
@maxWidth='20rem'
{{on 'input' (fn @changeType credentialType)}}
as |R|
>
<R.Label>{{t (concat 'resources.credential.types.' credentialType)}}
</R.Label>
<R.Description>{{t
(concat 'resources.credential.help.' credentialType)
}}
</R.Description>
</G.RadioCard>
{{/each}}
</Hds::Form::RadioCard::Group>
<Form::Credential::Radio
@model={{@model}}
@types={{@types}}
@changeType={{@changeType}}
/>
<Hds::Form::Fieldset as |F|>
<F.Legend>{{t 'resources.credential.form.json.label'}}</F.Legend>
<F.HelperText>{{t 'resources.credential.form.json.help'}}</F.HelperText>
@@ -0,0 +1,27 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Hds::Form::Radio::Group
class='credential-selection'
@name={{t 'form.type.label'}}
as |G|
>
<G.Legend>{{t 'form.type.label'}}</G.Legend>
{{#each @types as |credentialType|}}
<G.RadioField
@value={{credentialType}}
checked={{eq credentialType @model.type}}
{{on 'input' (fn @changeType credentialType)}}
as |F|
>
<F.Label>{{t
(concat 'resources.credential.types.' credentialType)
}}</F.Label>
<F.HelperText>{{t
(concat 'resources.credential.help.' credentialType)
}}</F.HelperText>
</G.RadioField>
{{/each}}
</Hds::Form::Radio::Group>
@@ -51,27 +51,13 @@
</F.Error>
{{/if}}
</Hds::Form::Textarea::Field>
{{#if @model.isNew}}
<Hds::Form::RadioCard::Group @name={{t 'form.type.label'}} as |G|>
<G.Legend>{{t 'form.type.label'}}</G.Legend>
{{#each @types as |credentialType|}}
<G.RadioCard
@value={{credentialType}}
@maxWidth='20rem'
@checked={{eq credentialType @model.type}}
{{on 'input' (fn @changeType credentialType)}}
as |R|
>
<R.Label>{{t
(concat 'resources.credential.types.' credentialType)
}}</R.Label>
<R.Description>{{t
(concat 'resources.credential.help.' credentialType)
}}</R.Description>
</G.RadioCard>
{{/each}}
</Hds::Form::RadioCard::Group>
{{#if @model.isNew}}
<Form::Credential::Radio
@model={{@model}}
@types={{@types}}
@changeType={{@changeType}}
/>
{{else}}
<InfoField
@value={{t (concat 'resources.credential.types.' @model.type)}}
@@ -83,7 +69,6 @@
</F.HelperText>
</InfoField>
{{/if}}
<Hds::Form::TextInput::Field
name='username'
@value={{@model.username}}
@@ -53,26 +53,11 @@
</Hds::Form::Textarea::Field>
{{#if @model.isNew}}
<Hds::Form::RadioCard::Group @name={{t 'form.type.label'}} as |G|>
<G.Legend>{{t 'form.type.label'}}</G.Legend>
{{#each @types as |credentialType|}}
<G.RadioCard
@value={{credentialType}}
@maxWidth='20rem'
@checked={{eq credentialType @model.type}}
{{on 'input' (fn @changeType credentialType)}}
as |R|
>
<R.Label>{{t
(concat 'resources.credential.types.' credentialType)
}}</R.Label>
<R.Description>{{t
(concat 'resources.credential.help.' credentialType)
}}</R.Description>
</G.RadioCard>
{{/each}}
</Hds::Form::RadioCard::Group>
<Form::Credential::Radio
@model={{@model}}
@types={{@types}}
@changeType={{@changeType}}
/>
{{else}}
<InfoField
@value={{t (concat 'resources.credential.types.' @model.type)}}
@@ -0,0 +1,152 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Rose::Form
@onSubmit={{this.handleSubmit}}
@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}}
@isOptional={{true}}
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}}
@isOptional={{true}}
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::Radio
@model={{@model}}
@types={{@types}}
@changeType={{@changeType}}
/>
{{else}}
<InfoField
@value={{t (concat 'resources.credential.types.' @model.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}}
<Hds::Form::TextInput::Field
name='username'
@isRequired={{true}}
@value={{@model.username}}
@isInvalid={{@model.errors.username}}
disabled={{form.disabled}}
{{on 'input' (set-from-event @model 'username')}}
{{on 'blur' this.handleUsername}}
as |F|
>
<F.Label>{{t 'resources.credential.form.username.label'}}</F.Label>
<F.HelperText>{{t 'resources.credential.form.username.help'}}</F.HelperText>
{{#if @model.errors.username}}
<F.Error as |E|>
{{#each @model.errors.username as |error|}}
<E.Message>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
{{#if (or @model.isNew form.isEditable)}}
<Hds::Form::TextInput::Field
name='password'
@isRequired={{true}}
@type='password'
@value={{@model.password}}
@isInvalid={{@model.errors.password}}
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}}
<Hds::Form::TextInput::Field
name='domain'
@isRequired={{true}}
@value={{@model.domain}}
@isInvalid={{@model.errors.domain}}
disabled={{form.disabled}}
{{on 'input' (set-from-event @model 'domain')}}
as |F|
>
<F.Label>{{t 'resources.credential.form.domain.label'}}</F.Label>
<F.HelperText>{{t 'resources.credential.form.domain.help'}}</F.HelperText>
{{#if @model.errors.domain}}
<F.Error as |E|>
{{#each @model.errors.domain as |error|}}
<E.Message
data-test-error-message-domain
>{{error.message}}</E.Message>
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::TextInput::Field>
{{#if (can 'save model' @model)}}
<form.actions
@enableEditText={{t 'actions.edit-form'}}
@submitText={{t 'actions.save'}}
@cancelText={{t 'actions.cancel'}}
/>
{{/if}}
</Rose::Form>
@@ -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;
}
}
}
}
@@ -149,7 +149,7 @@
</Hds::Form::TextInput::Field>
<Hds::Form::Radio::Group
class='dynamic-credential-selection'
class='credential-selection'
@layout='vertical'
@name='credential_type'
as |G|
@@ -68,7 +68,7 @@ export default class ScopesScopeCredentialStoresCredentialStoreCredentialsIndexC
*/
@action
@loading
@notifyError(({ message }) => message)
@notifyError(({ message }) => message, { catch: true })
@notifySuccess(({ isNew }) =>
isNew ? 'notifications.create-success' : 'notifications.save-success',
)
+1 -1
View File
@@ -967,7 +967,7 @@
}
}
.dynamic-credential-selection {
.credential-selection {
.hds-form-group__control-field {
margin-bottom: 0;
}
@@ -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: {
@@ -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,
);
});
});
@@ -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);
});
});
@@ -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 =
@@ -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) {
@@ -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: {
@@ -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.');
});
},
);
@@ -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: {
@@ -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]';
@@ -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');
@@ -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"]';
@@ -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();
});
@@ -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`<Form::Credential::UsernamePasswordDomain @model={{this.model}} @submit={{this.submit}} @cancel={{this.cancel}} />`,
);
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`
<Form::Credential::UsernamePasswordDomain
@model={{this.model}}
@submit={{this.submit}}
@cancel={{this.cancel}}
/>`);
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`
<Form::Credential::UsernamePasswordDomain
@model={{this.model}}
@submit={{this.submit}}
@cancel={{this.cancel}}
/>`);
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`
<Form::Credential::UsernamePasswordDomain
@model={{this.model}}
@submit={{this.submit}}
@cancel={{this.cancel}}
/>`);
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`
<Form::Credential::UsernamePasswordDomain
@model={{this.model}}
@submit={{this.submit}}
@cancel={{this.cancel}}
/>`);
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`
<Form::Credential::UsernamePasswordDomain
@model={{this.model}}
@submit={{this.submit}}
@cancel={{this.cancel}}
/>`);
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`
<Form::Credential::UsernamePasswordDomain
@model={{this.model}}
@submit={{this.submit}}
@cancel={{this.cancel}}
/>`);
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`
<Form::Credential::UsernamePasswordDomain
@model={{this.model}}
@submit={{this.submit}}
@cancel={{this.cancel}}
/>`);
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`
<Form::Credential::UsernamePasswordDomain
@model={{this.model}}
@submit={{this.submit}}
@cancel={{this.cancel}}
/>`);
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');
});
},
);