diff --git a/addons/api/mirage/factories/target.js b/addons/api/mirage/factories/target.js index c677e139f..c29767132 100644 --- a/addons/api/mirage/factories/target.js +++ b/addons/api/mirage/factories/target.js @@ -8,7 +8,16 @@ import { trait } from 'miragejs'; import { faker } from '@faker-js/faker'; import permissions from '../helpers/permissions'; import generateId from '../helpers/id'; -import { TYPES_TARGET, TYPE_TARGET_SSH } from 'api/models/target'; +import { + TYPES_TARGET, + TYPE_TARGET_SSH, + TYPE_TARGET_RDP, +} from 'api/models/target'; +import { + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + TYPE_CREDENTIAL_USERNAME_PASSWORD, + TYPE_CREDENTIAL_JSON, +} from 'api/models/credential'; const randomBoolean = (chance = 0.5) => Math.random() < chance; const randomFilter = () => @@ -16,6 +25,35 @@ const randomFilter = () => const hostSetChance = 0.3; const types = [...TYPES_TARGET]; +/** + * Helper to select items from a collection based on a condition. + */ +const selectItems = (collection, condition) => + collection + .where(condition) + .models.filter(() => randomBoolean()) + .map((item) => item.id); + +/** + * Helper to update target with selected credential sources and host sets. + */ +const updateTarget = ( + target, + hostSets, + isInjectable, + injectedSources, + brokeredSources, +) => { + const fieldsToUpdate = { + hostSets, + brokeredCredentialSourceIds: brokeredSources, + }; + if (isInjectable) { + fieldsToUpdate.injectedApplicationCredentialSourceIds = injectedSources; + } + target.update(fieldsToUpdate); +}; + export default factory.extend({ authorized_actions: () => permissions.authorizedActionsFor('target') || [ @@ -45,29 +83,83 @@ export default factory.extend({ */ withAssociations: trait({ afterCreate(target, server) { - let randomlySelectedHostSets, - randomlySelectedCredentialLibraries, - randomlySelectedCredentials; - randomlySelectedHostSets = server.schema.hostSets + const { scope } = target; + + const randomlySelectedHostSets = server.schema.hostSets // BLERG: fun fact, for no reason at all, the element passed // into a where function is not a full model instance, as you might // expect at this level of abstraction, but appears to be a serialized // representation of the model instance. It's very confusing since // the result set of `where` _is a collection of model instances_. - .where((hostSet) => hostSet.scopeId === target.scope.id) + .where((hostSet) => hostSet.scopeId === scope.id) .models.filter(() => randomBoolean(hostSetChance)); - randomlySelectedCredentialLibraries = server.schema.credentialLibraries - .where( + const randomlySelectedBrokeredCredentialSources = [ + ...selectItems( + server.schema.credentialLibraries, (credentialLibrary) => credentialLibrary.scopeId === target.scope.id, - ) - .models.filter(() => randomBoolean()) - .map((cred) => cred.id); - randomlySelectedCredentials = server.schema.credentials - .where((credential) => credential.scopeId === target.scope.id) - .models.filter(() => randomBoolean()) - .map((cred) => cred.id); + ), + ...selectItems( + server.schema.credentials, + (credential) => credential.scopeId === target.scope.id, + ), + ]; + // Select compatible credential libraries and credentials for injection + // Since not every credential or credential library can be injected, we filter them to mimic the expected behavior. + const filteredCredentialLibraries = selectItems( + server.schema.credentialLibraries, + (cred) => + cred.scopeId === scope.id && + cred.credential_type !== TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + ); + const filteredCredentials = selectItems( + server.schema.credentials, + (cred) => + cred.scopeId === scope.id && + ![ + TYPE_CREDENTIAL_JSON, + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + ].includes(cred.type), + ); + + // RDP specific credential libraries and credentials + const filteredCredentialLibrariesForRDP = selectItems( + server.schema.credentialLibraries, + (cred) => + cred.scopeId === scope.id && + [ + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + TYPE_CREDENTIAL_USERNAME_PASSWORD, + ].includes(cred.credential_type), + ); + const filteredCredentialsForRDP = selectItems( + server.schema.credentials, + (cred) => + cred.scopeId === scope.id && + [ + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + TYPE_CREDENTIAL_USERNAME_PASSWORD, + ].includes(cred.type), + ); + + // Update target based on type + const isInjectable = + target.type === TYPE_TARGET_SSH || target.type === TYPE_TARGET_RDP; + const injectedCredentialSources = + target.type === TYPE_TARGET_RDP + ? [...filteredCredentialLibrariesForRDP, ...filteredCredentialsForRDP] + : [...filteredCredentialLibraries, ...filteredCredentials]; + + updateTarget( + target, + randomlySelectedHostSets, + isInjectable, + injectedCredentialSources, + randomlySelectedBrokeredCredentialSources, + ); + + // Add storage bucket for SSH targets const storageBuckets = server.schema.storageBuckets.all().models; const randomlySelectedStorageBucket = storageBuckets.length === 0 @@ -81,6 +173,7 @@ export default factory.extend({ }); } + // Add Aliases const aliases = server.schema.aliases.all().models; const randomlySelectedAliases = aliases.length === 0 ? undefined : faker.helpers.arrayElement(aliases); @@ -89,17 +182,6 @@ export default factory.extend({ aliases: randomlySelectedAliases.aliases, }); } - target.update({ - hostSets: randomlySelectedHostSets, - brokeredCredentialSourceIds: [ - ...randomlySelectedCredentialLibraries, - ...randomlySelectedCredentials, - ], - injectedApplicationCredentialSourceIds: [ - ...randomlySelectedCredentialLibraries, - ...randomlySelectedCredentials, - ], - }); }, }), diff --git a/addons/api/mirage/scenarios/default.js b/addons/api/mirage/scenarios/default.js index 1e90829f7..a0356c010 100644 --- a/addons/api/mirage/scenarios/default.js +++ b/addons/api/mirage/scenarios/default.js @@ -122,8 +122,8 @@ export default function (server) { { scope, type: 'plugin' }, 'withChildren', ); - server.createList('credential-store', 3, { scope }, 'withAssociations'); - server.createList('target', 2, { scope }, 'withAssociations'); + server.createList('credential-store', 5, { scope }, 'withAssociations'); + server.createList('target', 4, { scope }, 'withAssociations'); server.create('target', { scope, address: '0.0.0.0' }); // Sessions have target data. Create it after targets. server.createList('session', 4, { scope }, 'withAssociations'); diff --git a/ui/admin/app/controllers/scopes/scope/targets/target/add-injected-application-credential-sources.js b/ui/admin/app/controllers/scopes/scope/targets/target/add-injected-application-credential-sources.js index bf5b0fd1c..89fb1b66e 100644 --- a/ui/admin/app/controllers/scopes/scope/targets/target/add-injected-application-credential-sources.js +++ b/ui/admin/app/controllers/scopes/scope/targets/target/add-injected-application-credential-sources.js @@ -8,6 +8,28 @@ import { service } from '@ember/service'; import { action } from '@ember/object'; import { loading } from 'ember-loading'; import { notifySuccess, notifyError } from 'core/decorators/notify'; +import { + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + TYPE_CREDENTIAL_USERNAME_PASSWORD, + TYPE_CREDENTIAL_SSH_PRIVATE_KEY, +} from 'api/models/credential'; +import { + TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, + TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, +} from 'api/models/credential-library'; +import { TYPE_TARGET_RDP, TYPE_TARGET_SSH } from 'api/models/target'; + +const ALLOWED_CREDENTIALS_FOR_SSH = [ + TYPE_CREDENTIAL_SSH_PRIVATE_KEY, + TYPE_CREDENTIAL_USERNAME_PASSWORD, + TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, + TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, +]; +const ALLOWED_CREDENTIALS_FOR_RDP = [ + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + TYPE_CREDENTIAL_USERNAME_PASSWORD, + TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, +]; export default class ScopesScopeTargetsTargetAddInjectedApplicationCredentialSourcesController extends Controller { // =services @@ -25,23 +47,59 @@ export default class ScopesScopeTargetsTargetAddInjectedApplicationCredentialSou } /** - * Filter out credential sources not already added to the target. - * @type {[CredentialLibraryModel, CredentialModel]} + * Provides a filtered list of credential sources available for injection. + * + * This list excludes sources that are already added to the target and only + * includes sources that are compatible with the target's type (SSH or RDP). + * + * @type {Array} */ get filteredCredentialSources() { + const { target, credentialLibraries, credentials } = this.model; + // Get IDs for credential sources already added to the current target const currentCredentialSourceIDs = new Set( - this.model.target.injected_application_credential_source_ids.map( + this.model.target.injected_application_credential_source_ids?.map( (source) => source.value, ), ); - const notAddedCredentialLibraries = this.model.credentialLibraries.filter( - ({ id }) => !currentCredentialSourceIDs.has(id), - ); - const notAddedCredentials = this.model.credentials.filter( - ({ id, type }) => !currentCredentialSourceIDs.has(id) && type !== 'json', - ); - return [...notAddedCredentialLibraries, ...notAddedCredentials]; + + // Filter out credential sources that are already added to the target + const availableCredentialSources = [ + ...credentialLibraries, + ...credentials, + ].filter(({ id }) => !currentCredentialSourceIDs.has(id)); + + // Filter based on target type and credential type + let allowedTypes = []; + if (target.type === TYPE_TARGET_SSH) { + allowedTypes = ALLOWED_CREDENTIALS_FOR_SSH; + } else if (target.type === TYPE_TARGET_RDP) { + allowedTypes = ALLOWED_CREDENTIALS_FOR_RDP; + } + + if (allowedTypes.length === 0) { + return []; + } + return this.#filterByAllowedTypes(availableCredentialSources, allowedTypes); + } + + /** + * Filters credential sources by allowed types. + * @param {Array} sources - The list of credential sources. + * @param {Array} allowedTypes - The allowed credential types. + * @returns {Array} - Filtered credential sources. + * @private + */ + #filterByAllowedTypes(sources, allowedTypes) { + return sources.filter(({ type, credential_type }) => { + // If the source is vault generic, we need to check the credential_type + // to determine if it is allowed + if (type === TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC) { + return allowedTypes.includes(credential_type); + } + return allowedTypes.includes(type); + }); } // =actions diff --git a/ui/admin/tests/acceptance/targets/injected-application-credential-sources-test.js b/ui/admin/tests/acceptance/targets/injected-application-credential-sources-test.js index 761eca0de..07612fdda 100644 --- a/ui/admin/tests/acceptance/targets/injected-application-credential-sources-test.js +++ b/ui/admin/tests/acceptance/targets/injected-application-credential-sources-test.js @@ -10,10 +10,14 @@ import setupMirage from 'ember-cli-mirage/test-support/setup-mirage'; import { setupSqlite } from 'api/test-support/helpers/sqlite'; import { Response } from 'miragejs'; import { authenticateSession } from 'ember-simple-auth/test-support'; -import { TYPE_TARGET_SSH } from 'api/models/target'; +import { TYPE_TARGET_SSH, TYPE_TARGET_RDP } from 'api/models/target'; import * as commonSelectors from 'admin/tests/helpers/selectors'; import * as selectors from './selectors'; -import { TYPE_CREDENTIAL_USERNAME_PASSWORD } from 'api/models/credential'; +import { + TYPE_CREDENTIAL_USERNAME_PASSWORD, + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + TYPE_CREDENTIAL_JSON, +} from 'api/models/credential'; import { TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC } from 'api/models/credential-library'; import { setRunOptions } from 'ember-a11y-testing/test-support'; @@ -26,9 +30,14 @@ module( let getCredentialLibraryCount; let getCredentialCount; + let getCredentialForRDPCount; + let getCredentialLibraryForRDPCount; let credentialSourceCount; - let randomlySelectedCredentialLibraries; - let randomlySelectedCredentials; + let filteredCredentialLibraries; + let filteredCredentials; + let filteredCredentialLibrariesForRDP; + let filteredCredentialsForRDP; + let credentialSourceForRDPCount; const instances = { scopes: { @@ -48,13 +57,22 @@ module( orgScope: null, projectScope: null, targets: null, - target: null, + sshTarget: null, + rdpTarget: null, credentialLibraries: null, credentialLibrary: null, - addInjectedApplicationCredentialSources: null, - injectedApplicationCredentialSources: null, + addInjectedApplicationCredentialSourcesForSSHTarget: null, + addInjectedApplicationCredentialSourcesForRDPTarget: null, + injectedApplicationCredentialSourcesForSSHTarget: null, + injectedApplicationCredentialSourcesForRDPTarget: null, }; + /** + * Helper to select items from a collection based on a condition. + */ + const selectItems = (collection, condition) => + collection.where(condition).models.map((item) => item.id); + hooks.beforeEach(async function () { // Generate resources instances.scopes.global = this.server.create('scope', { id: 'global' }); @@ -74,13 +92,13 @@ module( type: 'static', scope: instances.scopes.project, }); - instances.credentials = this.server.createList('credential', 2, { + instances.credentials = this.server.createList('credential', 6, { scope: instances.scopes.project, credentialStore: instances.staticCredentialStore, }); instances.credentialLibraries = this.server.createList( 'credential-library', - 2, + 6, { scope: instances.scopes.project, credentialStore: instances.vaultCredentialStore, @@ -88,63 +106,146 @@ module( ); instances.credentialLibrary = instances.credentialLibraries[0]; instances.credential = instances.credentials[0]; - instances.target = this.server.create('target', { + + filteredCredentials = selectItems( + this.server.schema.credentials, + (cred) => + ![ + TYPE_CREDENTIAL_JSON, + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + ].includes(cred.type), + ); + + filteredCredentialLibraries = selectItems( + this.server.schema.credentialLibraries, + (cred) => + cred.credential_type !== TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + ); + + // credentials for RDP + filteredCredentialLibrariesForRDP = selectItems( + this.server.schema.credentialLibraries, + (cred) => + [ + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + TYPE_CREDENTIAL_USERNAME_PASSWORD, + ].includes(cred.credential_type), + ); + filteredCredentialsForRDP = selectItems( + this.server.schema.credentials, + (cred) => + [ + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + TYPE_CREDENTIAL_USERNAME_PASSWORD, + ].includes(cred.type), + ); + + instances.sshTarget = this.server.create('target', { scope: instances.scopes.project, type: TYPE_TARGET_SSH, - }); - randomlySelectedCredentials = this.server.schema.credentials - .all() - .models.map((cred) => cred.id); - randomlySelectedCredentialLibraries = - this.server.schema.credentialLibraries - .all() - .models.map((cred) => cred.id); - instances.target.update({ injectedApplicationCredentialSourceIds: [ - ...randomlySelectedCredentialLibraries, - ...randomlySelectedCredentials, + ...filteredCredentials, + ...filteredCredentialLibraries, ], }); + + instances.rdpTarget = this.server.create('target', { + scope: instances.scopes.project, + type: TYPE_TARGET_RDP, + injectedApplicationCredentialSourceIds: [ + ...filteredCredentialsForRDP, + ...filteredCredentialLibrariesForRDP, + ], + }); + // Generate route URLs for resources urls.globalScope = `/scopes/global/scopes`; urls.orgScope = `/scopes/${instances.scopes.org.id}/scopes`; urls.projectScope = `/scopes/${instances.scopes.project.id}`; urls.targets = `${urls.projectScope}/targets`; - urls.target = `${urls.targets}/${instances.target.id}`; - urls.injectedApplicationCredentialSources = `${urls.target}/injected-application-credential-sources`; + urls.sshTarget = `${urls.targets}/${instances.sshTarget.id}`; + urls.rdpTarget = `${urls.targets}/${instances.rdpTarget.id}`; + urls.injectedApplicationCredentialSourcesForSSHTarget = `${urls.sshTarget}/injected-application-credential-sources`; + urls.injectedApplicationCredentialSourcesForRDPTarget = `${urls.rdpTarget}/injected-application-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.addInjectedApplicationCredentialSources = `${urls.target}/add-injected-application-credential-sources`; + urls.addInjectedApplicationCredentialSourcesForSSHTarget = `${urls.sshTarget}/add-injected-application-credential-sources`; + urls.addInjectedApplicationCredentialSourcesForRDPTarget = `${urls.rdpTarget}/add-injected-application-credential-sources`; + + // SSH specific credential sources getCredentialLibraryCount = () => - this.server.schema.credentialLibraries.all().models.length; + this.server.schema.credentialLibraries.where( + (c) => c.credential_type !== TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + ).models.length; getCredentialCount = () => - this.server.schema.credentials.all().models.length; + this.server.schema.credentials.where( + (cred) => + ![ + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + TYPE_CREDENTIAL_JSON, + ].includes(cred.type), + ).models.length; + + // RDP specific credential sources + getCredentialForRDPCount = () => + this.server.schema.credentials.where((cred) => + [ + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + TYPE_CREDENTIAL_USERNAME_PASSWORD, + ].includes(cred.type), + ).models.length; + getCredentialLibraryForRDPCount = () => + this.server.schema.credentialLibraries.where((cred) => + [ + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + TYPE_CREDENTIAL_USERNAME_PASSWORD, + ].includes(cred.credential_type), + ).models.length; + credentialSourceCount = getCredentialLibraryCount() + getCredentialCount(); + credentialSourceForRDPCount = + getCredentialLibraryForRDPCount() + getCredentialForRDPCount(); await authenticateSession({ username: 'admin' }); }); - test('visiting target injected application credential sources', async function (assert) { - setRunOptions({ - rules: { - 'color-contrast': { - // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01 - enabled: false, - }, + test.each( + 'visiting target injected application credential sources', + { + 'for SSH target': { + targetName: 'sshTarget', + route: 'injectedApplicationCredentialSourcesForSSHTarget', + expectedCount: () => credentialSourceCount, }, - }); + 'for RDP target': { + targetName: 'rdpTarget', + route: 'injectedApplicationCredentialSourcesForRDPTarget', + expectedCount: () => credentialSourceForRDPCount, + }, + }, + async function (assert, input) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01 + enabled: false, + }, + }, + }); + const url = urls[input.route]; + const expectedCount = input.expectedCount(); - await visit(urls.injectedApplicationCredentialSources); + await visit(url); - assert.strictEqual( - currentURL(), - urls.injectedApplicationCredentialSources, - ); - assert - .dom(commonSelectors.TABLE_ROWS) - .isVisible({ count: credentialSourceCount }); - }); + await a11yAudit(); + + assert.strictEqual(currentURL(), url); + assert + .dom(commonSelectors.TABLE_ROWS) + .isVisible({ count: expectedCount }); + }, + ); test('can navigate to a vault type credential library', async function (assert) { setRunOptions({ @@ -156,7 +257,7 @@ module( }, }); - await visit(urls.injectedApplicationCredentialSources); + await visit(urls.injectedApplicationCredentialSourcesForSSHTarget); await click(commonSelectors.TABLE_RESOURCE_LINK(urls.credentialLibrary)); @@ -173,96 +274,68 @@ module( }, }); - instances.target.update({ - injectedApplicationCredentialSourceIds: [ - ...randomlySelectedCredentials, - ], + instances.sshTarget.update({ + injectedApplicationCredentialSourceIds: [...filteredCredentials], }); - await visit(urls.injectedApplicationCredentialSources); + await visit(urls.injectedApplicationCredentialSourcesForSSHTarget); await click(commonSelectors.TABLE_RESOURCE_LINK(urls.credential)); assert.strictEqual(currentURL(), urls.credential); }); - test('visiting add injected application credential sources', async function (assert) { - setRunOptions({ - rules: { - 'color-contrast': { - // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01 - enabled: false, + test.each( + 'displays the correct list of available credential sources to add', + { + 'when all sources are available': { + getInjectedIds: () => [], + assertVisible: (assert) => { + assert + .dom(commonSelectors.TABLE_ROWS) + .isVisible({ count: credentialSourceCount }); + assert.dom(commonSelectors.PAGE_MESSAGE_HEADER).doesNotExist(); }, }, - }); - - await visit(urls.addInjectedApplicationCredentialSources); - - assert.strictEqual( - currentURL(), - urls.addInjectedApplicationCredentialSources, - ); - }); - - test('displays list of all injected application credential source types available', async function (assert) { - setRunOptions({ - rules: { - 'color-contrast': { - // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01 - enabled: false, + 'when only credentials (not libraries) are available': { + getInjectedIds: () => filteredCredentialLibraries, + assertVisible: (assert) => { + assert + .dom(commonSelectors.TABLE_ROWS) + .isVisible({ count: getCredentialCount() }); + assert.dom(commonSelectors.PAGE_MESSAGE_HEADER).doesNotExist(); }, }, - }); - - instances.target.update({ - injectedApplicationCredentialSourceIds: [], - }); - await visit(urls.addInjectedApplicationCredentialSources); - - assert - .dom(commonSelectors.TABLE_ROWS) - .isVisible({ count: credentialSourceCount }); - assert.dom(commonSelectors.PAGE_MESSAGE_HEADER).doesNotExist(); - }); - - test('displays list of injected application credential sources with only credential libraries available', async function (assert) { - setRunOptions({ - rules: { - 'color-contrast': { - // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01 - enabled: false, + 'when no sources are available': { + getInjectedIds: () => [ + ...filteredCredentialLibraries, + ...filteredCredentials, + ], + assertVisible: (assert) => { + assert + .dom(commonSelectors.PAGE_MESSAGE_HEADER) + .hasText('No Injected Application Credential Sources Available'); + assert.dom(commonSelectors.TABLE_ROWS).doesNotExist(); }, }, - }); - - instances.target.update({ - injectedApplicationCredentialSourceIds: [ - ...randomlySelectedCredentialLibraries, - ], - }); - await visit(urls.addInjectedApplicationCredentialSources); - - assert - .dom(commonSelectors.TABLE_ROWS) - .isVisible({ count: getCredentialLibraryCount() }); - assert.dom(commonSelectors.PAGE_MESSAGE_HEADER).doesNotExist(); - }); - - test('displays no injected application credential sources message when none available', async function (assert) { - setRunOptions({ - rules: { - 'color-contrast': { - // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01 - enabled: false, + }, + async function (assert, input) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01 + enabled: false, + }, }, - }, - }); + }); + instances.sshTarget.update({ + injectedApplicationCredentialSourceIds: input.getInjectedIds(), + }); - await visit(urls.addInjectedApplicationCredentialSources); + await visit(urls.addInjectedApplicationCredentialSourcesForSSHTarget); - assert - .dom(commonSelectors.PAGE_MESSAGE_HEADER) - .hasText('No Injected Application Credential Sources Available'); - }); + input.assertVisible(assert); + }, + ); test('when no injected application credential sources available, button routes to add injected application credential sources', async function (assert) { setRunOptions({ @@ -274,22 +347,22 @@ module( }, }); - instances.target.update({ + instances.sshTarget.update({ injectedApplicationCredentialSourceIds: [], }); - await visit(urls.injectedApplicationCredentialSources); + await visit(urls.injectedApplicationCredentialSourcesForSSHTarget); // Click on the rose message link await click(commonSelectors.PAGE_MESSAGE_LINK); assert.strictEqual( currentURL(), - urls.addInjectedApplicationCredentialSources, + urls.addInjectedApplicationCredentialSourcesForSSHTarget, ); }); test.each( - 'can select credential sources', + 'can select credential sources for SSH target', { 'save vault generic credential-library': { credentialSources: [TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC], @@ -338,10 +411,10 @@ module( }, }); - instances.target.update({ + instances.sshTarget.update({ injectedApplicationCredentialSourceIds: [], }); - await visit(urls.injectedApplicationCredentialSources); + await visit(urls.injectedApplicationCredentialSourcesForSSHTarget); assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count: 0 }); @@ -350,8 +423,9 @@ module( assert.strictEqual( currentURL(), - urls.addInjectedApplicationCredentialSources, + urls.addInjectedApplicationCredentialSourcesForSSHTarget, ); + assert .dom(commonSelectors.TABLE_ROWS) .isVisible({ count: credentialSourceCount }); @@ -363,7 +437,95 @@ module( assert.strictEqual( currentURL(), - urls.injectedApplicationCredentialSources, + urls.injectedApplicationCredentialSourcesForSSHTarget, + ); + + assert + .dom(commonSelectors.TABLE_ROWS) + .isVisible({ count: input.expectedCount }); + }, + ); + + test.each( + 'can select credential sources for RDP target', + { + 'save username and password credential': { + credentialSources: [TYPE_CREDENTIAL_USERNAME_PASSWORD], + action: commonSelectors.SAVE_BTN, + expectedCount: 1, + }, + 'save username, password & domain credential': { + credentialSources: [TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN], + action: commonSelectors.SAVE_BTN, + expectedCount: 1, + }, + 'save credentials and credential-libraries': { + credentialSources: [ + TYPE_CREDENTIAL_USERNAME_PASSWORD, + TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + ], + action: commonSelectors.SAVE_BTN, + expectedCount: 2, + }, + + 'cancel vault generic credential-library': { + credentialSources: [TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC], + action: commonSelectors.CANCEL_BTN, + expectedCount: 0, + }, + 'cancel username and password credential': { + credentialSources: [TYPE_CREDENTIAL_USERNAME_PASSWORD], + action: commonSelectors.CANCEL_BTN, + expectedCount: 0, + }, + 'cancel username, password & domain credential': { + credentialSources: [TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN], + action: commonSelectors.CANCEL_BTN, + expectedCount: 0, + }, + 'cancel credentials and credential-libraries': { + credentialSources: [ + TYPE_CREDENTIAL_USERNAME_PASSWORD, + TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, + ], + action: commonSelectors.CANCEL_BTN, + expectedCount: 0, + }, + }, + async function (assert, input) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01 + enabled: false, + }, + }, + }); + instances.rdpTarget.update({ + injectedApplicationCredentialSourceIds: [], + }); + await visit(urls.injectedApplicationCredentialSourcesForRDPTarget); + + assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count: 0 }); + + await click(selectors.MANAGE_DROPDOWN); + await click(selectors.MANGE_DROPDOWN_ADD_INJECTED_CREDENTIALS); + + assert.strictEqual( + currentURL(), + urls.addInjectedApplicationCredentialSourcesForRDPTarget, + ); + assert + .dom(commonSelectors.TABLE_ROWS) + .isVisible({ count: credentialSourceForRDPCount }); + for (const type of input.credentialSources) { + await click(selectors.TABLE_CREDENTIAL_SOURCE_CHECKBOX(type)); + } + await click(input.action); + + assert.strictEqual( + currentURL(), + urls.injectedApplicationCredentialSourcesForRDPTarget, ); assert .dom(commonSelectors.TABLE_ROWS) @@ -371,29 +533,42 @@ module( }, ); - test('cannot add credential sources without proper authorization', async function (assert) { - setRunOptions({ - rules: { - 'color-contrast': { - // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01 - enabled: false, - }, + test.each( + 'cannot add credential sources without proper authorization', + { + 'for SSH target': { + targetName: 'sshTarget', + route: 'injectedApplicationCredentialSourcesForSSHTarget', }, - }); + 'for RDP target': { + targetName: 'rdpTarget', + route: 'injectedApplicationCredentialSourcesForRDPTarget', + }, + }, + async function (assert, input) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01 + enabled: false, + }, + }, + }); + const target = instances[input.targetName]; + const url = urls[input.route]; - assert.expect(1); - instances.target.authorized_actions = - instances.target.authorized_actions.filter( + target.authorized_actions = target.authorized_actions.filter( (item) => item !== 'add-credential-sources', ); - await visit(urls.injectedApplicationCredentialSources); + await visit(url); - await click(selectors.MANAGE_DROPDOWN); + await click(selectors.MANAGE_DROPDOWN); - assert - .dom(selectors.MANGE_DROPDOWN_ADD_INJECTED_CREDENTIALS) - .doesNotExist(); - }); + assert + .dom(selectors.MANGE_DROPDOWN_ADD_INJECTED_CREDENTIALS) + .doesNotExist(); + }, + ); test('adding credential sources which errors displays error message', async function (assert) { setRunOptions({ @@ -417,10 +592,10 @@ module( }, ); }); - instances.target.update({ + instances.sshTarget.update({ injectedApplicationCredentialSourceIds: [], }); - await visit(urls.addInjectedApplicationCredentialSources); + await visit(urls.addInjectedApplicationCredentialSourcesForSSHTarget); await click( selectors.TABLE_CREDENTIAL_SOURCE_CHECKBOX( @@ -437,175 +612,131 @@ module( assert.dom(commonSelectors.ALERT_TOAST_BODY).isVisible(); }); - test('can remove a vault type credential library', async function (assert) { - setRunOptions({ - rules: { - 'color-contrast': { - // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01 - enabled: false, - }, + test.each( + 'can remove an injected application credential source', + { + 'for a credential library': { + getCredentialSourceIds: () => filteredCredentialLibraries, + getCount: () => getCredentialLibraryCount(), }, - }); - - instances.target.update({ - injectedApplicationCredentialSourceIds: [ - ...randomlySelectedCredentialLibraries, - ], - }); - const credentialLibraryCount = getCredentialLibraryCount(); - await visit(urls.injectedApplicationCredentialSources); - - assert - .dom(commonSelectors.TABLE_ROWS) - .isVisible({ count: credentialLibraryCount }); - - await click(commonSelectors.TABLE_FIRST_ROW_ACTION_DROPDOWN); - await click(commonSelectors.TABLE_FIRST_ROW_ACTION_DROPDOWN_ITEM_BTN); - - assert - .dom(commonSelectors.TABLE_ROWS) - .isVisible({ count: credentialLibraryCount - 1 }); - - await click(selectors.MANAGE_DROPDOWN); - await click(selectors.MANGE_DROPDOWN_ADD_INJECTED_CREDENTIALS); - - assert - .dom(commonSelectors.TABLE_ROWS) - .isVisible({ count: credentialLibraryCount + 1 }); - }); - - test('can remove a username & password type credential', async function (assert) { - setRunOptions({ - rules: { - 'color-contrast': { - // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01 - enabled: false, - }, + 'for a credential': { + getCredentialSourceIds: () => filteredCredentials, + getCount: () => getCredentialCount(), }, - }); - - instances.target.update({ - injectedApplicationCredentialSourceIds: [ - ...randomlySelectedCredentials, - ], - }); - const credentialCount = getCredentialCount(); - await visit(urls.injectedApplicationCredentialSources); - - assert - .dom(commonSelectors.TABLE_ROWS) - .isVisible({ count: credentialCount }); - - await click(commonSelectors.TABLE_FIRST_ROW_ACTION_DROPDOWN); - await click(commonSelectors.TABLE_FIRST_ROW_ACTION_DROPDOWN_ITEM_BTN); - - assert - .dom(commonSelectors.TABLE_ROWS) - .isVisible({ count: credentialCount - 1 }); - - await click(selectors.MANAGE_DROPDOWN); - await click(selectors.MANGE_DROPDOWN_ADD_INJECTED_CREDENTIALS); - - assert - .dom(commonSelectors.TABLE_ROWS) - .isVisible({ count: credentialCount + 1 }); - }); - - test('cannot remove credential libraries without proper authorization', async function (assert) { - setRunOptions({ - rules: { - 'color-contrast': { - // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01 - enabled: false, + }, + async function (assert, input) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01 + enabled: false, + }, }, - }, - }); + }); - instances.target.authorized_actions = - instances.target.authorized_actions.filter( + const credentialSourceIds = input.getCredentialSourceIds(); + const count = input.getCount(); + + instances.sshTarget.update({ + injectedApplicationCredentialSourceIds: credentialSourceIds, + }); + + await visit(urls.injectedApplicationCredentialSourcesForSSHTarget); + + assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count }); + + await click(commonSelectors.TABLE_FIRST_ROW_ACTION_DROPDOWN); + await click(commonSelectors.TABLE_FIRST_ROW_ACTION_DROPDOWN_ITEM_BTN); + + assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count: count - 1 }); + }, + ); + + test.each( + 'cannot remove credential sources without proper authorization', + { + 'for SSH target': { + targetName: 'sshTarget', + route: 'injectedApplicationCredentialSourcesForSSHTarget', + }, + 'for RDP target': { + targetName: 'rdpTarget', + route: 'injectedApplicationCredentialSourcesForRDPTarget', + }, + }, + async function (assert, input) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01 + enabled: false, + }, + }, + }); + + const target = instances[input.targetName]; + const url = urls[input.route]; + + target.authorized_actions = target.authorized_actions.filter( (item) => item !== 'remove-credential-sources', ); - await visit(urls.injectedApplicationCredentialSources); + await visit(url); - assert - .dom(commonSelectors.TABLE_FIRST_ROW_ACTION_DROPDOWN) - .doesNotExist(); - }); + assert + .dom(commonSelectors.TABLE_FIRST_ROW_ACTION_DROPDOWN) + .doesNotExist(); + }, + ); - test('removing a target credential library which errors displays error messages', async function (assert) { - setRunOptions({ - rules: { - 'color-contrast': { - // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01 - enabled: false, - }, + test.each( + 'removing a target credential source which errors displays an error message', + { + 'for a credential library': { + getCredentialSourceIds: () => filteredCredentialLibraries, + getCount: () => getCredentialLibraryCount(), }, - }); - - instances.target.update({ - injectedApplicationCredentialSourceIds: [ - ...randomlySelectedCredentialLibraries, - ], - }); - this.server.post('/targets/:idMethod', () => { - return new Response( - 400, - {}, - { - status: 400, - code: 'invalid_argument', - message: 'The request was invalid.', - details: {}, - }, - ); - }); - const count = getCredentialLibraryCount(); - await visit(urls.injectedApplicationCredentialSources); - - assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count }); - - await click(commonSelectors.TABLE_FIRST_ROW_ACTION_DROPDOWN); - await click(commonSelectors.TABLE_FIRST_ROW_ACTION_DROPDOWN_ITEM_BTN); - - assert.dom(commonSelectors.ALERT_TOAST_BODY).isVisible(); - }); - - test('removing a target credential which errors displays error messages', async function (assert) { - setRunOptions({ - rules: { - 'color-contrast': { - // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01 - enabled: false, - }, + 'for a credential': { + getCredentialSourceIds: () => filteredCredentials, + getCount: () => getCredentialCount(), }, - }); - - instances.target.update({ - injectedApplicationCredentialSourceIds: [ - ...randomlySelectedCredentials, - ], - }); - this.server.post('/targets/:idMethod', () => { - return new Response( - 400, - {}, - { - status: 400, - code: 'invalid_argument', - message: 'The request was invalid.', - details: {}, + }, + async function (assert, input) { + setRunOptions({ + rules: { + 'color-contrast': { + // [ember-a11y-ignore]: axe rule "color-contrast" automatically ignored on 2025-08-01 + enabled: false, + }, }, - ); - }); - const count = getCredentialCount(); - await visit(urls.injectedApplicationCredentialSources); + }); - assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count }); + const credentialSourceIds = input.getCredentialSourceIds(); + const count = input.getCount(); - await click(commonSelectors.TABLE_FIRST_ROW_ACTION_DROPDOWN); - await click(commonSelectors.TABLE_FIRST_ROW_ACTION_DROPDOWN_ITEM_BTN); + instances.sshTarget.update({ + injectedApplicationCredentialSourceIds: credentialSourceIds, + }); - assert.dom(commonSelectors.ALERT_TOAST_BODY).isVisible(); - }); + this.server.post('/targets/:idMethod', () => { + return new Response( + 400, + {}, + { + error: 'The request was invalid.', + }, + ); + }); + + await visit(urls.injectedApplicationCredentialSourcesForSSHTarget); + + assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count }); + + await click(commonSelectors.TABLE_FIRST_ROW_ACTION_DROPDOWN); + await click(commonSelectors.TABLE_FIRST_ROW_ACTION_DROPDOWN_ITEM_BTN); + + assert.dom(commonSelectors.ALERT_TOAST_BODY).isVisible(); + assert.dom(commonSelectors.TABLE_ROWS).isVisible({ count }); + }, + ); }, ); diff --git a/ui/admin/tests/unit/controllers/scopes/scope/targets/target/add-injected-application-credential-sources-test.js b/ui/admin/tests/unit/controllers/scopes/scope/targets/target/add-injected-application-credential-sources-test.js index bb6959174..d13acd16b 100644 --- a/ui/admin/tests/unit/controllers/scopes/scope/targets/target/add-injected-application-credential-sources-test.js +++ b/ui/admin/tests/unit/controllers/scopes/scope/targets/target/add-injected-application-credential-sources-test.js @@ -9,6 +9,17 @@ import { currentURL, visit } from '@ember/test-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_USERNAME_PASSWORD_DOMAIN, + TYPE_CREDENTIAL_USERNAME_PASSWORD, + TYPE_CREDENTIAL_SSH_PRIVATE_KEY, + TYPE_CREDENTIAL_JSON, +} from 'api/models/credential'; +import { + TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, + TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, +} from 'api/models/credential-library'; +import { TYPE_TARGET_SSH, TYPE_TARGET_RDP } from 'api/models/target'; module( 'Unit | Controller | scopes/scope/targets/target/add-injected-application-credential-sources', @@ -68,29 +79,210 @@ module( assert.ok(controller); }); - test('hasAvailableInjectedApplicationCredentialSources returns true if target has available credentials', function (assert) { + test('hasAvailableInjectedApplicationCredentialSources returns true when compatible, unassigned sources exist for an SSH target', function (assert) { const target = store.createRecord('target', { - injected_application_credential_source_ids: [{ value: 'cred_123' }], + injected_application_credential_source_ids: [], + type: TYPE_TARGET_SSH, }); const credentialLibraries = [ - store.createRecord('credential-library', {}), + store.createRecord('credential-library', { + credential_type: TYPE_CREDENTIAL_USERNAME_PASSWORD, + }), + ]; + const credentials = [ + store.createRecord('credential', { + type: TYPE_CREDENTIAL_USERNAME_PASSWORD, + }), ]; - const credentials = [store.createRecord('credential', {})]; controller.set('model', { target, credentialLibraries, credentials }); + // Should be true as username_password is compatible with SSH assert.true(controller.hasAvailableInjectedApplicationCredentialSources); }); - test('hasAvailableInjectedApplicationCredentialSources returns false if target has no available credentials', function (assert) { + test('hasAvailableInjectedApplicationCredentialSources returns false when only incompatible sources exist for an SSH target', function (assert) { + const target = store.createRecord('target', { + injected_application_credential_source_ids: [], + type: TYPE_TARGET_SSH, + }); + const credentialLibraries = [ + store.createRecord('credential-library', { + credential_type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + }), + ]; + const credentials = [ + store.createRecord('credential', { + type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + }), + ]; + controller.set('model', { target, credentialLibraries, credentials }); + + // Should be false as username_password_domain is not compatible with SSH + assert.false(controller.hasAvailableInjectedApplicationCredentialSources); + }); + + test('hasAvailableInjectedApplicationCredentialSources returns true when compatible sources exist for an RDP target', function (assert) { + const target = store.createRecord('target', { + injected_application_credential_source_ids: [], + type: TYPE_TARGET_RDP, + }); + const credentialLibraries = [ + store.createRecord('credential-library', { + credential_type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + }), + ]; + const credentials = [ + store.createRecord('credential', { + type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + }), + ]; + controller.set('model', { target, credentialLibraries, credentials }); + + // Should be true as username_password_domain is compatible with RDP + assert.true(controller.hasAvailableInjectedApplicationCredentialSources); + }); + + test('hasAvailableInjectedApplicationCredentialSources returns false when no compatible sources exist for an RDP target', function (assert) { + const target = store.createRecord('target', { + injected_application_credential_source_ids: [], + type: TYPE_TARGET_RDP, + }); + const credentialLibraries = [ + store.createRecord('credential-library', { + credential_type: TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, + }), + ]; + const credentials = [ + store.createRecord('credential', { + type: TYPE_CREDENTIAL_JSON, + }), + ]; + controller.set('model', { target, credentialLibraries, credentials }); + + // Should be false as SSH certificate and JSON are not compatible with RDP + assert.false(controller.hasAvailableInjectedApplicationCredentialSources); + }); + + test.each( + 'filteredCredentialSources correctly filters sources based on target type', + [ + { + model: 'credential-library', + type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, + credential_type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + isInjectableForSSH: false, + isInjectableForRDP: true, + }, + { + model: 'credential-library', + type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, + credential_type: TYPE_CREDENTIAL_USERNAME_PASSWORD, + isInjectableForSSH: true, + isInjectableForRDP: true, + }, + { + model: 'credential', + type: TYPE_CREDENTIAL_USERNAME_PASSWORD, + credential_type: undefined, + isInjectableForSSH: true, + isInjectableForRDP: true, + }, + { + model: 'credential', + type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN, + credential_type: undefined, + isInjectableForSSH: false, + isInjectableForRDP: true, + }, + { + model: 'credential-library', + type: TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE, + credential_type: undefined, + isInjectableForSSH: true, + isInjectableForRDP: false, + }, + { + model: 'credential', + type: TYPE_CREDENTIAL_SSH_PRIVATE_KEY, + credential_type: undefined, + isInjectableForSSH: true, + isInjectableForRDP: false, + }, + { + model: 'credential', + type: TYPE_CREDENTIAL_JSON, + credential_type: undefined, + isInjectableForSSH: false, + isInjectableForRDP: false, + }, + { + model: 'credential-library', + type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC, + credential_type: TYPE_CREDENTIAL_SSH_PRIVATE_KEY, + isInjectableForSSH: true, + isInjectableForRDP: false, + }, + ], + function (assert, input) { + const record = store.createRecord(input.model, { + type: input.type, + credential_type: input.credential_type, + }); + const sshTarget = store.createRecord('target', { + type: TYPE_TARGET_SSH, + }); + const rdpTarget = store.createRecord('target', { + type: TYPE_TARGET_RDP, + }); + + const credentialLibraries = + input.model === 'credential-library' ? [record] : []; + const credentials = input.model === 'credential' ? [record] : []; + + // Test for SSH target + controller.set('model', { + target: sshTarget, + credentialLibraries, + credentials, + }); + assert.strictEqual( + controller.filteredCredentialSources.length === 1, + input.isInjectableForSSH, + `SSH target: Expected isInjectableForSSH to be ${input.isInjectableForSSH} for type ${input.type} and credential_type ${input.credential_type}`, + ); + + // Test for RDP target + controller.set('model', { + target: rdpTarget, + credentialLibraries, + credentials, + }); + assert.strictEqual( + controller.filteredCredentialSources.length === 1, + input.isInjectableForRDP, + `RDP target: Expected isInjectableForRDP to be ${input.isInjectableForRDP} for type ${input.type} and credential_type ${input.credential_type}`, + ); + }, + ); + + test('filteredCredentialSources excludes sources that are already injected', function (assert) { + const credential = store.createRecord('credential', { + id: 'cred_123', + type: TYPE_CREDENTIAL_USERNAME_PASSWORD, + }); const target = store.createRecord('target', { injected_application_credential_source_ids: [{ value: 'cred_123' }], + type: TYPE_TARGET_SSH, }); + controller.set('model', { target, credentialLibraries: [], - credentials: [], + credentials: [credential], }); + // The credential should be filtered out since it's already injected + assert.strictEqual(controller.filteredCredentialSources.length, 0); assert.false(controller.hasAvailableInjectedApplicationCredentialSources); });