From 088bb4b6b9d9b61f96973a9c607583e3d1960325 Mon Sep 17 00:00:00 2001 From: Angel Garbarino Date: Fri, 24 Jan 2025 11:05:00 -0700 Subject: [PATCH] One WIF configuration component (#29367) * make one component and make one test file for that component. remove the two components and associated files the new component replaces * make access type subtext dynamic based on model type * clean up * clean up * remove model attr for display purposes * split out lease to another second config model type and make is-wif-engine helper * welp missed the old controller * small removal of overkill comment * pr feedback * save lease config if only thing changed * error handling in acceptance test * test fix * replace notOk with throw * move back error message * clean up focused largely on wif component test * replace ok with true --- .../secret-engine/configure-aws.hbs | 136 --- .../components/secret-engine/configure-aws.ts | 232 ----- .../secret-engine/configure-azure.ts | 184 ---- ...{configure-azure.hbs => configure-wif.hbs} | 65 +- .../components/secret-engine/configure-wif.ts | 252 +++++ .../secrets/backend/configuration/edit.js | 58 +- ui/app/models/aws/root-config.js | 10 +- ui/app/models/azure/config.js | 12 +- .../secrets/backend/configuration/edit.ts | 31 +- .../secrets/backend/configuration/edit.hbs | 23 +- .../backend/aws/aws-configuration-test.js | 208 +++-- .../backend/azure/azure-configuration-test.js | 60 +- .../backend/gcp/gcp-configuration-test.js | 4 +- .../secret-engine/secret-engine-selectors.ts | 4 +- .../secret-engine/configure-aws-test.js | 578 ------------ .../secret-engine/configure-azure-test.js | 426 --------- .../secret-engine/configure-wif-test.js | 866 ++++++++++++++++++ ui/types/vault/models/aws/lease-config.d.ts | 22 - ui/types/vault/models/aws/root-config.d.ts | 32 - ui/types/vault/models/azure/config.d.ts | 34 - .../secret-engine/additional-config.d.ts | 28 + .../models/secret-engine/mount-config.d.ts | 53 ++ 22 files changed, 1487 insertions(+), 1831 deletions(-) delete mode 100644 ui/app/components/secret-engine/configure-aws.hbs delete mode 100644 ui/app/components/secret-engine/configure-aws.ts delete mode 100644 ui/app/components/secret-engine/configure-azure.ts rename ui/app/components/secret-engine/{configure-azure.hbs => configure-wif.hbs} (55%) create mode 100644 ui/app/components/secret-engine/configure-wif.ts delete mode 100644 ui/tests/integration/components/secret-engine/configure-aws-test.js delete mode 100644 ui/tests/integration/components/secret-engine/configure-azure-test.js create mode 100644 ui/tests/integration/components/secret-engine/configure-wif-test.js delete mode 100644 ui/types/vault/models/aws/lease-config.d.ts delete mode 100644 ui/types/vault/models/aws/root-config.d.ts delete mode 100644 ui/types/vault/models/azure/config.d.ts create mode 100644 ui/types/vault/models/secret-engine/additional-config.d.ts create mode 100644 ui/types/vault/models/secret-engine/mount-config.d.ts diff --git a/ui/app/components/secret-engine/configure-aws.hbs b/ui/app/components/secret-engine/configure-aws.hbs deleted file mode 100644 index bc0109f193..0000000000 --- a/ui/app/components/secret-engine/configure-aws.hbs +++ /dev/null @@ -1,136 +0,0 @@ -{{! - Copyright (c) HashiCorp, Inc. - SPDX-License-Identifier: BUSL-1.1 -~}} - -
-
- - -

- Note: the client uses the official AWS SDK and will use the specified credentials, environment credentials, shared file - credentials, or IAM role/ECS task credentials in that order. -

-
- {{! Root configuration details }} -

- Access to AWS -

-
- {{! WIF is an enterprise only feature. We default to IAM access type for community users and display only those related form fields. }} - {{#if this.version.isEnterprise}} -
- Access Type -

- {{#if this.disableAccessType}} - You cannot edit Access Type if you have already saved access credentials. - {{else}} - Choose the way to configure access to AWS. Access can be configured either with IAM access keys, or using Plugin - Workload Identity Federation (WIF).{{/if}}

-
- - - - - -
-
- {{/if}} - {{#if (eq this.accessType "wif")}} - {{! WIF Fields }} - {{#each @issuerConfig.displayAttrs as |attr|}} - - {{/each}} - - {{else}} - {{! IAM Fields }} - - {{/if}} -
- - {{! Lease configuration details }} -

- Leases -

-
- {{#each @leaseConfig.displayAttrs as |attr|}} - - {{/each}} -
- -
-
- - -
- {{#if this.invalidFormAlert}} - - {{/if}} -
-
- -{{#if this.saveIssuerWarning}} - - - Are you sure? - - -

- {{this.saveIssuerWarning}} -

-
- - - - - - -
-{{/if}} \ No newline at end of file diff --git a/ui/app/components/secret-engine/configure-aws.ts b/ui/app/components/secret-engine/configure-aws.ts deleted file mode 100644 index 98d3309a94..0000000000 --- a/ui/app/components/secret-engine/configure-aws.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import Component from '@glimmer/component'; -import { action } from '@ember/object'; -import { task } from 'ember-concurrency'; -import { waitFor } from '@ember/test-waiters'; -import { service } from '@ember/service'; -import { tracked } from '@glimmer/tracking'; -import { ValidationMap } from 'vault/vault/app-types'; -import errorMessage from 'vault/utils/error-message'; - -import type LeaseConfigModel from 'vault/models/aws/lease-config'; -import type RootConfigModel from 'vault/models/aws/root-config'; -import type IdentityOidcConfigModel from 'vault/models/identity/oidc/config'; -import type Router from '@ember/routing/router'; -import type StoreService from 'vault/services/store'; -import type VersionService from 'vault/services/version'; -import type FlashMessageService from 'vault/services/flash-messages'; - -/** - * @module ConfigureAwsComponent is used to configure the AWS secret engine - * A user can configure the endpoint root/config and/or lease/config. - * For enterprise users, they will see an additional option to config WIF attributes in place of IAM attributes. - * The fields for these endpoints are on one form. - * - * @example - * ```js - * - * ``` - * - * @param {object} rootConfig - AWS config/root model - * @param {object} leaseConfig - AWS config/lease model - * @param {string} backendPath - name of the AWS secret engine, ex: 'aws-123' - */ - -interface Args { - leaseConfig: LeaseConfigModel; - rootConfig: RootConfigModel; - issuerConfig: IdentityOidcConfigModel; - backendPath: string; - issuer?: string; -} - -export default class ConfigureAwsComponent extends Component { - @service declare readonly router: Router; - @service declare readonly store: StoreService; - @service declare readonly version: VersionService; - @service declare readonly flashMessages: FlashMessageService; - - @tracked errorMessageRoot: string | null = null; - @tracked errorMessageLease: string | null = null; - @tracked invalidFormAlert: string | null = null; - @tracked modelValidationsLease: ValidationMap | null = null; - @tracked accessType = 'iam'; - @tracked saveIssuerWarning = ''; - - disableAccessType = false; - - constructor(owner: unknown, args: Args) { - super(owner, args); - // the following checks are only relevant to enterprise users and those editing an existing root configuration. - if (this.version.isCommunity || this.args.rootConfig.isNew) return; - const { roleArn, identityTokenAudience, identityTokenTtl, accessKey } = this.args.rootConfig; - // do not include issuer in this check. Issuer is a global endpoint and can be set even if we're not editing wif attributes - const wifAttributesSet = !!roleArn || !!identityTokenAudience || !!identityTokenTtl; - const iamAttributesSet = !!accessKey; - // If any WIF attributes have been set in the rootConfig model, set accessType to 'wif'. - this.accessType = wifAttributesSet ? 'wif' : 'iam'; - // If there are either WIF or IAM attributes set then disable user's ability to change accessType. - this.disableAccessType = wifAttributesSet || iamAttributesSet; - } - - @action continueSubmitForm() { - // called when the user confirms they are okay with the issuer change - this.saveIssuerWarning = ''; - this.save.perform(); - } - - // on form submit - validate inputs and check for issuer changes - submitForm = task( - waitFor(async (event: Event) => { - event?.preventDefault(); - this.resetErrors(); - const { leaseConfig, issuerConfig } = this.args; - // Note: only aws/lease-config model has validations - const isValid = this.validate(leaseConfig); - if (!isValid) return; - if (issuerConfig?.hasDirtyAttributes) { - // if the issuer has changed show modal with warning that the config will change - // if the modal is shown, the user has to click confirm to continue save - this.saveIssuerWarning = `You are updating the global issuer config. This will overwrite Vault's current issuer ${ - issuerConfig.queryIssuerError ? 'if it exists ' : '' - }and may affect other configurations using this value. Continue?`; - // exit task until user confirms - return; - } - await this.save.perform(); - }) - ); - - save = task( - waitFor(async () => { - // when we get here, the models have already been validated so just continue with save - const { leaseConfig, rootConfig, issuerConfig } = this.args; - // Check if any of the models' attributes have changed. - // If no changes to either model, transition and notify user. - // If changes to either model, save the model(s) that changed and notify user. - // Note: "backend" dirties model state so explicity ignore it here. - const leaseAttrChanged = Object.keys(leaseConfig?.changedAttributes()).some( - (item) => item !== 'backend' - ); - const rootAttrChanged = Object.keys(rootConfig?.changedAttributes()).some((item) => item !== 'backend'); - const issuerAttrChanged = issuerConfig?.hasDirtyAttributes; - if (!leaseAttrChanged && !rootAttrChanged && !issuerAttrChanged) { - this.flashMessages.info('No changes detected.'); - this.transition(); - return; - } - // Attempt saves of changed models. If at least one of them succeed, transition - const rootSaved = rootAttrChanged ? await this.saveRoot() : false; - const leaseSaved = leaseAttrChanged ? await this.saveLease() : false; - const issuerSaved = issuerAttrChanged ? await this.updateIssuer() : false; - - if (rootSaved || leaseSaved || issuerSaved) { - this.transition(); - } else { - // otherwise there was a failure and we should not transition and exit the function. - return; - } - }) - ); - - async updateIssuer(): Promise { - try { - await this.args.issuerConfig.save(); - this.flashMessages.success('Issuer saved successfully'); - return true; - } catch (e) { - this.flashMessages.danger(`Issuer was not saved: ${errorMessage(e, 'Check Vault logs for details.')}`); - return false; - } - } - - async saveRoot(): Promise { - const { backendPath, rootConfig } = this.args; - try { - await rootConfig.save(); - this.flashMessages.success(`Successfully saved ${backendPath}'s root configuration.`); - return true; - } catch (error) { - this.errorMessageRoot = errorMessage(error); - this.invalidFormAlert = 'There was an error submitting this form.'; - return false; - } - } - - async saveLease(): Promise { - const { backendPath, leaseConfig } = this.args; - try { - await leaseConfig.save(); - this.flashMessages.success(`Successfully saved ${backendPath}'s lease configuration.`); - return true; - } catch (error) { - // if lease config fails, but there was no error saving rootConfig: notify user of the lease failure with a flash message, save the root config, and transition. - if (!this.errorMessageRoot) { - this.flashMessages.danger(`Lease configuration was not saved: ${errorMessage(error)}`, { - sticky: true, - }); - return true; - } else { - this.errorMessageLease = errorMessage(error); - this.flashMessages.danger( - `Configuration not saved: ${errorMessage(error)}. ${this.errorMessageRoot}` - ); - return false; - } - } - } - - resetErrors() { - this.flashMessages.clearMessages(); - this.errorMessageRoot = null; - this.invalidFormAlert = null; - } - - transition() { - this.router.transitionTo('vault.cluster.secrets.backend.configuration', this.args.backendPath); - } - - validate(model: LeaseConfigModel) { - const { isValid, state, invalidFormMessage } = model.validate(); - this.modelValidationsLease = isValid ? null : state; - this.invalidFormAlert = isValid ? '' : invalidFormMessage; - return isValid; - } - - unloadModels() { - this.args.rootConfig.unloadRecord(); - this.args.leaseConfig.unloadRecord(); - } - - @action - onChangeAccessType(accessType: string) { - this.accessType = accessType; - const { rootConfig } = this.args; - if (accessType === 'iam') { - // reset all WIF attributes - rootConfig.roleArn = rootConfig.identityTokenAudience = rootConfig.identityTokenTtl = undefined; - // for the issuer return to the globally set value (if there is one) on toggle - this.args.issuerConfig.rollbackAttributes(); - } - if (accessType === 'wif') { - // reset all IAM attributes - rootConfig.accessKey = rootConfig.secretKey = undefined; - } - } - - @action - onCancel() { - // clear errors because they're canceling out of the workflow. - this.resetErrors(); - this.unloadModels(); - this.transition(); - } -} diff --git a/ui/app/components/secret-engine/configure-azure.ts b/ui/app/components/secret-engine/configure-azure.ts deleted file mode 100644 index 45da5ccd07..0000000000 --- a/ui/app/components/secret-engine/configure-azure.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import Component from '@glimmer/component'; -import { action } from '@ember/object'; -import { task } from 'ember-concurrency'; -import { waitFor } from '@ember/test-waiters'; -import { service } from '@ember/service'; -import { tracked } from '@glimmer/tracking'; -import errorMessage from 'vault/utils/error-message'; - -import type ConfigModel from 'vault/models/azure/config'; -import type IdentityOidcConfigModel from 'vault/models/identity/oidc/config'; -import type Router from '@ember/routing/router'; -import type StoreService from 'vault/services/store'; -import type VersionService from 'vault/services/version'; -import type FlashMessageService from 'vault/services/flash-messages'; - -/** - * @module SecretEngineConfigureAzure component is used to configure the Azure secret engine - * For enterprise users, they will see an additional option to config WIF attributes in place of Azure account attributes. - * If the user is configuring WIF attributes they will also have the option to update the global issuer config, which is a separate endpoint named identity/oidc/config. - * @example - * - * - * @param {object} model - Azure config model - * @param {string} backendPath - name of the Azure secret engine, ex: 'azure-123' - * @param {object} issuerConfigModel - the identity/oidc/config model - */ - -interface Args { - model: ConfigModel; - issuerConfig: IdentityOidcConfigModel; - backendPath: string; -} - -export default class ConfigureAzureComponent extends Component { - @service declare readonly router: Router; - @service declare readonly store: StoreService; - @service declare readonly version: VersionService; - @service declare readonly flashMessages: FlashMessageService; - - @tracked accessType = 'azure'; - @tracked errorMessage = ''; - @tracked invalidFormAlert = ''; - @tracked saveIssuerWarning = ''; - - disableAccessType = false; - - constructor(owner: unknown, args: Args) { - super(owner, args); - // the following checks are only relevant to existing enterprise configurations - if (this.version.isCommunity && this.args.model.isNew) return; - const { isWifPluginConfigured, isAzureAccountConfigured } = this.args.model; - this.accessType = isWifPluginConfigured ? 'wif' : 'azure'; - // if there are either WIF or azure attributes, disable user's ability to change accessType - this.disableAccessType = isWifPluginConfigured || isAzureAccountConfigured; - } - - get modelAttrChanged() { - // "backend" dirties model state so explicity ignore it here - return Object.keys(this.args.model?.changedAttributes()).some((item) => item !== 'backend'); - } - - get issuerAttrChanged() { - return this.args.issuerConfig?.hasDirtyAttributes; - } - - @action continueSubmitForm() { - this.saveIssuerWarning = ''; - this.save.perform(); - } - - // check if the issuer has been changed to show issuer modal - // continue saving the configuration - submitForm = task( - waitFor(async (event: Event) => { - event?.preventDefault(); - this.resetErrors(); - - if (this.issuerAttrChanged) { - // if the issuer has changed show modal with warning that the config will change - // if the modal is shown, the user has to click confirm to continue saving - this.saveIssuerWarning = `You are updating the global issuer config. This will overwrite Vault's current issuer ${ - this.args.issuerConfig.queryIssuerError ? 'if it exists ' : '' - }and may affect other configurations using this value. Continue?`; - // exit task until user confirms - return; - } - await this.save.perform(); - }) - ); - - save = task( - waitFor(async () => { - const modelAttrChanged = this.modelAttrChanged; - const issuerAttrChanged = this.issuerAttrChanged; - // check if any of the model or issue attributes have changed - // if no changes, transition and notify user - if (!modelAttrChanged && !issuerAttrChanged) { - this.flashMessages.info('No changes detected.'); - this.transition(); - return; - } - - const modelSaved = modelAttrChanged ? await this.saveModel() : false; - const issuerSaved = issuerAttrChanged ? await this.updateIssuer() : false; - - if (modelSaved || (!modelAttrChanged && issuerSaved)) { - // transition if the model was saved successfully - // we only prevent a transition if the model is edited and fails saving - this.transition(); - } else { - // otherwise there was a failure and we should not transition and exit the function - return; - } - }) - ); - - async updateIssuer(): Promise { - try { - await this.args.issuerConfig.save(); - this.flashMessages.success('Issuer saved successfully'); - return true; - } catch (e) { - this.flashMessages.danger(`Issuer was not saved: ${errorMessage(e, 'Check Vault logs for details.')}`); - // remove issuer from the config model if it was not saved - this.args.issuerConfig.rollbackAttributes(); - return false; - } - } - - async saveModel(): Promise { - const { backendPath, model } = this.args; - try { - await model.save(); - this.flashMessages.success(`Successfully saved ${backendPath}'s configuration.`); - return true; - } catch (error) { - this.errorMessage = errorMessage(error); - this.invalidFormAlert = 'There was an error submitting this form.'; - return false; - } - } - - resetErrors() { - this.flashMessages.clearMessages(); - this.errorMessage = ''; - this.invalidFormAlert = ''; - } - - transition() { - this.router.transitionTo('vault.cluster.secrets.backend.configuration', this.args.backendPath); - } - - @action - onChangeAccessType(accessType: string) { - this.accessType = accessType; - const { model } = this.args; - if (accessType === 'azure') { - // reset all WIF attributes - model.identityTokenAudience = model.identityTokenTtl = undefined; - // return the issuer to the globally set value (if there is one) on toggle - this.args.issuerConfig.rollbackAttributes(); - } - if (accessType === 'wif') { - // reset all Azure attributes - model.clientSecret = model.rootPasswordTtl = undefined; - } - } - - @action - onCancel() { - this.resetErrors(); - this.args.model.unloadRecord(); - this.transition(); - } -} diff --git a/ui/app/components/secret-engine/configure-azure.hbs b/ui/app/components/secret-engine/configure-wif.hbs similarity index 55% rename from ui/app/components/secret-engine/configure-azure.hbs rename to ui/app/components/secret-engine/configure-wif.hbs index 64b98758f6..dd9921bbc0 100644 --- a/ui/app/components/secret-engine/configure-azure.hbs +++ b/ui/app/components/secret-engine/configure-wif.hbs @@ -3,11 +3,21 @@ SPDX-License-Identifier: BUSL-1.1 ~}} -
+ + {{! AWS specific note and section header }} + {{#if (eq @type "aws")}} +

+ Note: the client uses the official AWS SDK and will use the specified credentials, environment credentials, shared file + credentials, or IAM role/ECS task credentials in that order. +

+

+ Access to AWS +

+ {{/if}}
- {{! accessType can be "azure" or "wif" - since WIF is an enterprise only feature we default to "azure" for community users and only display those related form fields. }} + {{! Only enterprise users can change access type from "account" to "wif" }} {{#if this.version.isEnterprise}}
Access Type @@ -15,22 +25,24 @@ {{#if this.disableAccessType}} You cannot edit Access Type if you have already saved access credentials. {{else}} - Choose the way to configure access to Azure. Access can be configured either using an Azure account or with the - Plugin Workload Identity Federation (WIF). + Choose the way to configure access to + {{@displayName}}. Access can be configured either using + {{if (eq @type "aws") "IAM access keys" (concat @displayName " account credentials")}} + or with the Plugin Workload Identity Federation (WIF). {{/if}}

- + @@ -46,21 +58,38 @@
{{/if}} {{#if (eq this.accessType "wif")}} - {{! WIF Fields }} + {{! if access type is "wif" display Issuer and WIF fields }} {{#each @issuerConfig.displayAttrs as |attr|}} {{/each}} - - {{else}} - {{! Azure Account Fields }} + {{else}} + {{! otherwise display account credential fields }} + {{/if}} + {{! additionalConfigModel fields show regardless of the vault version or what access type is selected }} + {{#if @additionalConfigModel}} +

+ {{if (eq @type "aws") "Lease" "Additional"}} + Configuration +

+
+ {{#each @additionalConfigModel.formFields as |attr|}} + + {{/each}} +
+ {{/if}}
+ + * + * @param {string} backendPath - name of the secret engine, ex: 'azure-123'. + * @param {string} displayName - used for flash messages, subText and labels. ex: 'Azure'. + * @param {string} type - the type of the engine, ex: 'azure'. + * @param {object} mountConfigModel - the config model for the engine. The attr `isWifPluginConfigured` must be added to this config model otherwise this component will assert an error. `isWifPluginConfigured` should return true if any required wif fields have been set. + * @param {object} [additionalConfigModel] - for engines with two config models. Currently, only used by aws + * @param {object} [issuerConfig] - the identity/oidc/config model. Will be passed in if user has an enterprise license. + */ + +interface Args { + backendPath: string; + displayName: string; + type: string; + mountConfigModel: MountConfigModel; + additionalConfigModel: AdditionalConfigModel; + issuerConfig: IdentityOidcConfigModel; +} + +export default class ConfigureWif extends Component { + @service declare readonly router: Router; + @service declare readonly store: StoreService; + @service declare readonly version: VersionService; + @service declare readonly flashMessages: FlashMessageService; + + @tracked accessType = 'account'; // for community users they will not be able to change this. for enterprise users, they will have the option to select "wif". + @tracked errorMessage = ''; + @tracked invalidFormAlert = ''; + @tracked saveIssuerWarning = ''; + @tracked modelValidations: ValidationMap | null = null; + + disableAccessType = false; + + constructor(owner: unknown, args: Args) { + super(owner, args); + // the following checks are only relevant to existing enterprise configurations + if (this.version.isCommunity && this.args.mountConfigModel.isNew) return; + const { isWifPluginConfigured, isAccountPluginConfigured } = this.args.mountConfigModel; + assert( + `'isWifPluginConfigured' is required to be defined on the config model. Must return a boolean.`, + isWifPluginConfigured !== undefined + ); + this.accessType = isWifPluginConfigured ? 'wif' : 'account'; + // if wif or account only attributes are defined, disable the user's ability to change the access type + this.disableAccessType = isWifPluginConfigured || isAccountPluginConfigured; + } + + get mountConfigModelAttrChanged() { + // "backend" dirties model state so explicity ignore it here + return Object.keys(this.args.mountConfigModel?.changedAttributes()).some((item) => item !== 'backend'); + } + + get issuerAttrChanged() { + return this.args.issuerConfig?.hasDirtyAttributes; + } + + get additionalConfigModelAttrChanged() { + const { additionalConfigModel } = this.args; + // required to check for additional model otherwise Object.keys will have nothing to iterate over and fails + return additionalConfigModel + ? Object.keys(additionalConfigModel.changedAttributes()).some((item) => item !== 'backend') + : false; + } + + @action continueSubmitForm() { + this.saveIssuerWarning = ''; + this.save.perform(); + } + + // check if the issuer has been changed to show issuer modal + // continue saving the configuration + submitForm = task( + waitFor(async (event: Event) => { + event?.preventDefault(); + this.resetErrors(); + // currently we only check validations on the additional model + if (this.args.additionalConfigModel && !this.isValid(this.args.additionalConfigModel)) { + return; + } + if (this.issuerAttrChanged) { + // if the issuer has changed show modal with warning that the config will change + // if the modal is shown, the user has to click confirm to continue saving + this.saveIssuerWarning = `You are updating the global issuer config. This will overwrite Vault's current issuer ${ + this.args.issuerConfig.queryIssuerError ? 'if it exists ' : '' + }and may affect other configurations using this value. Continue?`; + // exit task until user confirms + return; + } + await this.save.perform(); + }) + ); + + save = task( + waitFor(async () => { + const mountConfigModelChanged = this.mountConfigModelAttrChanged; + const additionalModelAttrChanged = this.additionalConfigModelAttrChanged; + const issuerAttrChanged = this.issuerAttrChanged; + // check if any of the model(s) or issuer attributes have changed + // if no changes, transition and notify user + if (!mountConfigModelChanged && !additionalModelAttrChanged && !issuerAttrChanged) { + this.flashMessages.info('No changes detected.'); + this.transition(); + return; + } + + const mountConfigModelSaved = mountConfigModelChanged ? await this.saveMountConfigModel() : false; + const issuerSaved = issuerAttrChanged ? await this.updateIssuer() : false; + + if ( + mountConfigModelSaved || + (!mountConfigModelChanged && issuerSaved) || + (!mountConfigModelChanged && additionalModelAttrChanged) + ) { + // if there are changes made to the an additional model, attempt to save it. if saving fails, we transition and the failure will surface as a sticky flash message on the configuration details page. + if (additionalModelAttrChanged) { + await this.saveAdditionalConfigModel(); + } + // we only prevent a transition if the mount config model or issuer fail when saving + this.transition(); + } else { + return; + } + }) + ); + + async updateIssuer(): Promise { + try { + await this.args.issuerConfig.save(); + this.flashMessages.success('Issuer saved successfully'); + return true; + } catch (e) { + this.flashMessages.danger(`Issuer was not saved: ${errorMessage(e, 'Check Vault logs for details.')}`); + // remove issuer from the config model if it was not saved + this.args.issuerConfig.rollbackAttributes(); + return false; + } + } + + async saveMountConfigModel(): Promise { + const { backendPath, mountConfigModel } = this.args; + try { + await mountConfigModel.save(); + this.flashMessages.success(`Successfully saved ${backendPath}'s configuration.`); + return true; + } catch (error) { + this.errorMessage = errorMessage(error); + this.invalidFormAlert = 'There was an error submitting this form.'; + return false; + } + } + + async saveAdditionalConfigModel() { + const { backendPath, additionalConfigModel, type } = this.args; + const additionalConfigModelName = type === 'aws' ? 'lease configuration' : 'additional configuration'; + try { + await additionalConfigModel.save(); + this.flashMessages.success(`Successfully saved ${backendPath}'s ${additionalConfigModelName}.`); + } catch (error) { + // the only error the user sees is a sticky flash message on the next view. + this.flashMessages.danger( + `${capitalize(additionalConfigModelName)} was not saved: ${errorMessage(error)}`, + { + sticky: true, + } + ); + } + } + + resetErrors() { + this.flashMessages.clearMessages(); + this.errorMessage = this.invalidFormAlert = ''; + this.modelValidations = null; + } + + transition() { + this.router.transitionTo('vault.cluster.secrets.backend.configuration', this.args.backendPath); + } + + isValid(model: AdditionalConfigModel) { + const { isValid, state, invalidFormMessage } = model.validate(); + this.modelValidations = isValid ? null : state; + this.invalidFormAlert = isValid ? '' : invalidFormMessage; + return isValid; + } + + @action + onChangeAccessType(accessType: string) { + this.accessType = accessType; + const { mountConfigModel, type } = this.args; + if (accessType === 'account') { + // reset all "wif" attributes that are mutually exclusive with "account" attributes + // these attributes are the same for each engine + mountConfigModel.identityTokenAudience = mountConfigModel.identityTokenTtl = undefined; + // return the issuer to the globally set value (if there is one) on toggle + this.args.issuerConfig.rollbackAttributes(); + } + if (accessType === 'wif') { + // reset all "account" attributes that are mutually exclusive with "wif" attributes + // these attributes are different for each engine + type === 'azure' + ? (mountConfigModel.clientSecret = mountConfigModel.rootPasswordTtl = undefined) + : type === 'aws' + ? (mountConfigModel.accessKey = undefined) + : null; + } + } + + @action + onCancel() { + this.resetErrors(); + this.args.mountConfigModel.unloadRecord(); + this.transition(); + } +} diff --git a/ui/app/controllers/vault/cluster/secrets/backend/configuration/edit.js b/ui/app/controllers/vault/cluster/secrets/backend/configuration/edit.js index 6dbea5516b..63cdca50f8 100644 --- a/ui/app/controllers/vault/cluster/secrets/backend/configuration/edit.js +++ b/ui/app/controllers/vault/cluster/secrets/backend/configuration/edit.js @@ -3,54 +3,14 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { isPresent } from '@ember/utils'; -import { service } from '@ember/service'; import Controller from '@ember/controller'; +import { WIF_ENGINES, allEngines } from 'vault/helpers/mountable-secret-engines'; -const CONFIG_ATTRS = { - // ssh - configured: false, - - // aws root config - iamEndpoint: null, - stsEndpoint: null, - accessKey: null, - secretKey: null, - region: '', -}; - -export default Controller.extend(CONFIG_ATTRS, { - queryParams: ['tab'], - tab: '', - flashMessages: service(), - loading: false, - reset() { - this.model.rollbackAttributes(); - this.setProperties(CONFIG_ATTRS); - }, - actions: { - save(method, data) { - this.set('loading', true); - const hasData = Object.keys(data).some((key) => { - return isPresent(data[key]); - }); - if (!hasData) { - return; - } - this.model - .save({ - adapterOptions: { - adapterMethod: method, - data, - }, - }) - .then(() => { - this.reset(); - this.flashMessages.success('The backend configuration saved successfully!'); - }) - .finally(() => { - this.set('loading', false); - }); - }, - }, -}); +export default class SecretsBackendConfigurationEditController extends Controller { + get isWifEngine() { + return WIF_ENGINES.includes(this.model.type); + } + get displayName() { + return allEngines().find((engine) => engine.type === this.model.type)?.displayName; + } +} diff --git a/ui/app/models/aws/root-config.js b/ui/app/models/aws/root-config.js index 961b9a2489..405718ac96 100644 --- a/ui/app/models/aws/root-config.js +++ b/ui/app/models/aws/root-config.js @@ -74,21 +74,21 @@ export default class AwsRootConfig extends Model { return formFields.filter((attr) => attr.name !== 'secretKey'); } - // "filedGroupsWif" and "fieldGroupsIam" are passed to the FormFieldGroups component to determine which group to show in the form (ex: @groupName="fieldGroupsWif") + // "filedGroupsWif" and "fieldGroupsAccount" are passed to the FormFieldGroups component to determine which group to show in the form (ex: @groupName="fieldGroupsWif") get fieldGroupsWif() { return fieldToAttrs(this, this.formFieldGroups('wif')); } - get fieldGroupsIam() { - return fieldToAttrs(this, this.formFieldGroups('iam')); + get fieldGroupsAccount() { + return fieldToAttrs(this, this.formFieldGroups('account')); } - formFieldGroups(accessType = 'iam') { + formFieldGroups(accessType = 'account') { const formFieldGroups = []; if (accessType === 'wif') { formFieldGroups.push({ default: ['roleArn', 'identityTokenAudience', 'identityTokenTtl'] }); } - if (accessType === 'iam') { + if (accessType === 'account') { formFieldGroups.push({ default: ['accessKey', 'secretKey'] }); } formFieldGroups.push({ diff --git a/ui/app/models/azure/config.js b/ui/app/models/azure/config.js index a924ca658f..08f9a702ce 100644 --- a/ui/app/models/azure/config.js +++ b/ui/app/models/azure/config.js @@ -64,7 +64,7 @@ export default class AzureConfig extends Model { return !!this.identityTokenAudience || !!this.identityTokenTtl; } - get isAzureAccountConfigured() { + get isAccountPluginConfigured() { // clientSecret is not checked here because it's never return by the API // however it is an Azure account field return !!this.rootPasswordTtl; @@ -79,16 +79,16 @@ export default class AzureConfig extends Model { return formFields.filter((attr) => attr.name !== 'clientSecret'); } - // "filedGroupsWif" and "fieldGroupsAzure" are passed to the FormFieldGroups component to determine which group to show in the form (ex: @groupName="fieldGroupsWif") + // "filedGroupsWif" and "fieldGroupsAccount" are passed to the FormFieldGroups component to determine which group to show in the form (ex: @groupName="fieldGroupsWif") get fieldGroupsWif() { return fieldToAttrs(this, this.formFieldGroups('wif')); } - get fieldGroupsAzure() { - return fieldToAttrs(this, this.formFieldGroups('azure')); + get fieldGroupsAccount() { + return fieldToAttrs(this, this.formFieldGroups('account')); } - formFieldGroups(accessType = 'azure') { + formFieldGroups(accessType = 'account') { const formFieldGroups = []; formFieldGroups.push({ default: ['subscriptionId', 'tenantId', 'clientId', 'environment'], @@ -98,7 +98,7 @@ export default class AzureConfig extends Model { default: ['identityTokenAudience', 'identityTokenTtl'], }); } - if (accessType === 'azure') { + if (accessType === 'account') { formFieldGroups.push({ default: ['clientSecret', 'rootPasswordTtl'], }); diff --git a/ui/app/routes/vault/cluster/secrets/backend/configuration/edit.ts b/ui/app/routes/vault/cluster/secrets/backend/configuration/edit.ts index e56efa44e4..fd94c4c548 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/configuration/edit.ts +++ b/ui/app/routes/vault/cluster/secrets/backend/configuration/edit.ts @@ -19,8 +19,8 @@ import type VersionService from 'vault/services/version'; // It generates config models based on the engine type. // Saving and updating of those models are done within the engine specific components. -const CONFIG_ADAPTERS_PATHS: Record = { - aws: ['aws/lease-config', 'aws/root-config'], +const MOUNT_CONFIG_MODEL_NAMES: Record = { + aws: ['aws/root-config', 'aws/lease-config'], azure: ['azure/config'], ssh: ['ssh/ca-config'], }; @@ -29,6 +29,19 @@ export default class SecretsBackendConfigurationEdit extends Route { @service declare readonly store: Store; @service declare readonly version: VersionService; + standardizedModelName(type: string, modelName: string) { + // to determine if there is an additional config model, we check if the modelName is the same as the second element in the array. + const path = + MOUNT_CONFIG_MODEL_NAMES[type] && MOUNT_CONFIG_MODEL_NAMES[type].length > 1 + ? MOUNT_CONFIG_MODEL_NAMES[type][1] + : null; + if (modelName === path) { + return 'additional-config-model'; + } else { + return 'mount-config-model'; + } + } + async model() { const { backend } = this.paramsFor('vault.cluster.secrets.backend'); const secretEngineRecord = this.modelFor('vault.cluster.secrets.backend') as SecretEngineModel; @@ -43,12 +56,12 @@ export default class SecretsBackendConfigurationEdit extends Route { // generate the model based on the engine type. // and pre-set model with type and backend e.g. {type: ssh, id: ssh-123} const model: Record = { type, id: backend }; - for (const adapterPath of CONFIG_ADAPTERS_PATHS[type] as string[]) { - // convert the adapterPath with a name that can be passed to the components - // ex: adapterPath = ssh/ca-config, convert to: ssh-ca-config so that you can pass to component @model={{this.model.ssh-ca-config}} - const standardizedKey = adapterPath.replace(/\//g, '-'); + for (const modelName of MOUNT_CONFIG_MODEL_NAMES[type] as string[]) { + // create a key that corresponds with the model order + // ex: modelName = aws/lease-config, convert to: additional-config-model so that you can pass to component @additionalConfigModel={{this.model.additional-config-model}} + const standardizedKey = this.standardizedModelName(type, modelName); try { - const configModel = await this.store.queryRecord(adapterPath, { + const configModel = await this.store.queryRecord(modelName, { backend, type, }); @@ -56,7 +69,7 @@ export default class SecretsBackendConfigurationEdit extends Route { // so instead of checking a catch or httpStatus, we check if the model is configured based on the getter `isConfigured` on the engine's model // if the engine is not configured we update the record to get the default values if (!configModel.isConfigured && type === 'azure') { - model[standardizedKey] = await this.store.createRecord(adapterPath, { + model[standardizedKey] = await this.store.createRecord(modelName, { backend, type, }); @@ -71,7 +84,7 @@ export default class SecretsBackendConfigurationEdit extends Route { e.httpStatus === 404 || (type === 'ssh' && e.httpStatus === 400 && errorMessage(e) === `keys haven't been configured yet`) ) { - model[standardizedKey] = await this.store.createRecord(adapterPath, { + model[standardizedKey] = await this.store.createRecord(modelName, { backend, type, }); diff --git a/ui/app/templates/vault/cluster/secrets/backend/configuration/edit.hbs b/ui/app/templates/vault/cluster/secrets/backend/configuration/edit.hbs index 80d0c30628..8119e89db0 100644 --- a/ui/app/templates/vault/cluster/secrets/backend/configuration/edit.hbs +++ b/ui/app/templates/vault/cluster/secrets/backend/configuration/edit.hbs @@ -15,7 +15,7 @@

Configure - {{get (options-for-backend this.model.type) "displayName"}} + {{this.displayName}}

@@ -28,19 +28,16 @@ -{{#if (eq this.model.type "aws")}} - -{{else if (eq this.model.type "azure")}} - + {{! This "else if" check is preventive. As of writing, all engines using this route, but "ssh", are wif engines }} +{{else if this.isWifEngine}} + -{{else if (eq this.model.type "ssh")}} - {{/if}} \ No newline at end of file diff --git a/ui/tests/acceptance/secrets/backend/aws/aws-configuration-test.js b/ui/tests/acceptance/secrets/backend/aws/aws-configuration-test.js index 0d3545655b..365fa7a294 100644 --- a/ui/tests/acceptance/secrets/backend/aws/aws-configuration-test.js +++ b/ui/tests/acceptance/secrets/backend/aws/aws-configuration-test.js @@ -34,10 +34,12 @@ module('Acceptance | aws | configuration', function (hooks) { this.store = this.owner.lookup('service:store'); this.flashSuccessSpy = spy(flash, 'success'); this.flashInfoSpy = spy(flash, 'info'); + this.flashDangerSpy = spy(flash, 'danger'); this.version = this.owner.lookup('service:version'); this.uid = uuidv4(); return authPage.login(); }); + module('isEnterprise', function (hooks) { hooks.beforeEach(function () { this.version.type = 'enterprise'; @@ -62,7 +64,13 @@ module('Acceptance | aws | configuration', function (hooks) { await click(SES.configure); assert.strictEqual(currentURL(), `/vault/secrets/${path}/configuration/edit`); assert.dom(SES.configureTitle('aws')).hasText('Configure AWS'); - assert.dom(SES.aws.rootForm).exists('it lands on the root configuration form.'); + assert.dom(SES.configureForm).exists('it lands on the configuration form.'); + assert + .dom(SES.additionalConfigModelTitle) + .hasText( + 'Lease Configuration', + 'it shows the lease configuration section with the "Lease Configuration" title.' + ); // cleanup await runCmd(`delete sys/mounts/${path}`); }); @@ -83,9 +91,8 @@ module('Acceptance | aws | configuration', function (hooks) { await enablePage.enable('aws', path); this.server.post(configUrl('aws-lease', path), () => { - assert.false( - true, - 'post request was made to config/lease when no data was changed. test should fail.' + throw new Error( + 'A POST request was made to config/lease when it should not because no data was changed.' ); }); @@ -93,13 +100,13 @@ module('Acceptance | aws | configuration', function (hooks) { await click(SES.configure); await fillInAwsConfig('withWif'); await click(GENERAL.saveButton); - assert.dom(SES.wif.issuerWarningModal).exists('issue warning modal exists'); + assert.dom(SES.wif.issuerWarningModal).exists('issuer warning modal exists'); await click(SES.wif.issuerWarningSave); // three flash messages, the first is about mounting the engine, only care about the last two assert.strictEqual( this.flashSuccessSpy.args[1][0], - `Successfully saved ${path}'s root configuration.`, - 'first flash message about the root config.' + `Successfully saved ${path}'s configuration.`, + 'first flash message about the first model config.' ); assert.strictEqual( this.flashSuccessSpy.args[2][0], @@ -124,11 +131,11 @@ module('Acceptance | aws | configuration', function (hooks) { const type = 'aws'; this.server.get(`${path}/config/root`, (schema, req) => { const payload = JSON.parse(req.requestBody); - assert.ok(true, 'request made to config/root when navigating to the configuration page.'); + assert.true(true, 'request made to config/root when navigating to the configuration page.'); return { data: { id: path, type, attributes: payload } }; }); this.server.get(`identity/oidc/config`, () => { - assert.false(true, 'request made to return issuer. test should fail.'); + throw new Error(`Request was made to return the issuer when it should not have been.`); }); await enablePage.enable(type, path); createConfig(this.store, path, type); // create the aws root config in the store @@ -145,10 +152,7 @@ module('Acceptance | aws | configuration', function (hooks) { await enablePage.enable('aws', path); this.server.post(configUrl('aws-lease', path), () => { - assert.false( - true, - 'post request was made to config/lease when no data was changed. test should fail.' - ); + throw new Error(`post request was made to config/lease when it should not have been.`); }); await click(SES.configTab); @@ -156,8 +160,8 @@ module('Acceptance | aws | configuration', function (hooks) { await fillInAwsConfig('withAccess'); await click(GENERAL.saveButton); assert.true( - this.flashSuccessSpy.calledWith(`Successfully saved ${path}'s root configuration.`), - 'Success flash message is rendered showing the root configuration was saved.' + this.flashSuccessSpy.calledWith(`Successfully saved ${path}'s configuration.`), + 'Success flash message is rendered showing the configuration was saved.' ); assert.dom(GENERAL.infoRowValue('Access key')).hasText('foo', 'Access Key has been set.'); assert @@ -190,40 +194,12 @@ module('Acceptance | aws | configuration', function (hooks) { await runCmd(`delete sys/mounts/${path}`); }); - test('it should save lease AWS configuration', async function (assert) { - assert.expect(3); - const path = `aws-${this.uid}`; - await enablePage.enable('aws', path); - - this.server.post(configUrl('aws', path), () => { - assert.false( - true, - 'post request was made to config/root when no data was changed. test should fail.' - ); - }); - await click(SES.configTab); - await click(SES.configure); - await fillInAwsConfig('withLease'); - await click(GENERAL.saveButton); - assert.true( - this.flashSuccessSpy.calledWith(`Successfully saved ${path}'s lease configuration.`), - 'Success flash message is rendered showing the lease configuration was saved.' - ); - - assert - .dom(GENERAL.infoRowValue('Default Lease TTL')) - .hasText('33 seconds', `Default TTL has been set.`); - assert.dom(GENERAL.infoRowValue('Max Lease TTL')).hasText('44 seconds', `Max lease TTL has been set.`); - // cleanup - await runCmd(`delete sys/mounts/${path}`); - }); - test('it shows AWS mount configuration details', async function (assert) { const path = `aws-${this.uid}`; const type = 'aws'; this.server.get(`${path}/config/root`, (schema, req) => { const payload = JSON.parse(req.requestBody); - assert.ok(true, 'request made to config/root when navigating to the configuration page.'); + assert.true(true, 'request made to config/root when navigating to the configuration page.'); return { data: { id: path, type, attributes: payload } }; }); await enablePage.enable(type, path); @@ -280,19 +256,6 @@ module('Acceptance | aws | configuration', function (hooks) { await runCmd(`delete sys/mounts/${path}`); }); - test('it should show API error when AWS configuration read fails', async function (assert) { - assert.expect(1); - const path = `aws-${this.uid}`; - const type = 'aws'; - await enablePage.enable(type, path); - // interrupt get and return API error - this.server.get(configUrl(type, path), () => { - return overrideResponse(400, { errors: ['bad request'] }); - }); - await click(SES.configTab); - assert.dom(SES.error.title).hasText('Error', 'shows the secrets backend error route'); - }); - test('it should not make a post request if lease or root data was unchanged', async function (assert) { assert.expect(3); const path = `aws-${this.uid}`; @@ -300,16 +263,10 @@ module('Acceptance | aws | configuration', function (hooks) { await enablePage.enable(type, path); this.server.post(configUrl(type, path), () => { - assert.false( - true, - 'post request was made to config/root when no data was changed. test should fail.' - ); + throw new Error(`post request was made to config/root when it should not have been.`); }); this.server.post(configUrl('aws-lease', path), () => { - assert.false( - true, - 'post request was made to config/lease when no data was changed. test should fail.' - ); + throw new Error(`post request was made to config/lease when it should not have been.`); }); await click(SES.configTab); @@ -350,6 +307,35 @@ module('Acceptance | aws | configuration', function (hooks) { // cleanup await runCmd(`delete sys/mounts/${path}`); }); + + test('it saves lease configuration if root configuration was not changed', async function (assert) { + assert.expect(2); + const path = `aws-${this.uid}`; + await enablePage.enable('aws', path); + + this.server.post(configUrl('aws', path), () => { + throw new Error( + `Request was made to save the config/root when it should not have been because the user did not make any changes to this config.` + ); + }); + + await click(SES.configTab); + await click(SES.configure); + await fillInAwsConfig('withLease'); + await click(GENERAL.saveButton); + + assert.true( + this.flashSuccessSpy.calledWith(`Successfully saved ${path}'s lease configuration.`), + 'Success flash message is rendered showing the lease configuration was saved.' + ); + assert.strictEqual( + currentURL(), + `/vault/secrets/${path}/configuration`, + 'the form transitioned as expected to the details page' + ); + // cleanup + await runCmd(`delete sys/mounts/${path}`); + }); }); module('isCommunity', function (hooks) { @@ -377,5 +363,97 @@ module('Acceptance | aws | configuration', function (hooks) { // cleanup await runCmd(`delete sys/mounts/${path}`); }); + + module('Error handling', function () { + test('it does not try to save lease configuration if root configuration errored on save', async function (assert) { + assert.expect(1); + const path = `aws-${this.uid}`; + await enablePage.enable('aws', path); + + this.server.post(configUrl('aws', path), () => { + assert.true(true, 'post request was made to save aws root config.'); + return overrideResponse(400, { errors: ['bad request!'] }); + }); + this.server.post(configUrl('aws-lease', path), () => { + throw new Error( + `post request was made to config/lease when the first config was not saved. A request to this endpoint should NOT be be made` + ); + }); + await click(SES.configTab); + await click(SES.configure); + await fillInAwsConfig('withAccess'); + await fillInAwsConfig('withLease'); + await click(GENERAL.saveButton); + // cleanup + await runCmd(`delete sys/mounts/${path}`); + }); + + test('it shows a flash message error and transitions if lease configuration errored on save', async function (assert) { + assert.expect(2); + const path = `aws-${this.uid}`; + await enablePage.enable('aws', path); + + this.server.post(configUrl('aws', path), () => { + throw new Error( + `Request was made to save the config/root when it should not have been because the user did not make any changes to this config.` + ); + }); + this.server.post(configUrl('aws-lease', path), () => { + return overrideResponse(400, { errors: ['bad request!'] }); + }); + await click(SES.configTab); + await click(SES.configure); + await fillInAwsConfig('withLease'); + await click(GENERAL.saveButton); + + assert.true( + this.flashDangerSpy.calledWith(`Lease configuration was not saved: bad request!`), + 'flash danger message is rendered showing the lease configuration was NOT saved.' + ); + assert.strictEqual( + currentURL(), + `/vault/secrets/${path}/configuration`, + 'lease configuration failed to save but the component transitioned as expected' + ); + // cleanup + await runCmd(`delete sys/mounts/${path}`); + }); + + test('it prevents transition and shows api error if root config errored on save', async function (assert) { + const path = `aws-${this.uid}`; + await enablePage.enable('aws', path); + + this.server.post(configUrl('aws', path), () => { + return overrideResponse(400, { errors: ['welp, that did not work!'] }); + }); + + await click(SES.configTab); + await click(SES.configure); + await fillInAwsConfig('withAccess'); + await click(GENERAL.saveButton); + + assert.dom(GENERAL.messageError).hasText('Error welp, that did not work!', 'API error shows on form'); + assert.strictEqual( + currentURL(), + `/vault/secrets/${path}/configuration/edit`, + 'the form did not transition because the save failed.' + ); + // cleanup + await runCmd(`delete sys/mounts/${path}`); + }); + + test('it should show API error when AWS configuration read fails', async function (assert) { + assert.expect(1); + const path = `aws-${this.uid}`; + const type = 'aws'; + await enablePage.enable(type, path); + // interrupt get and return API error + this.server.get(configUrl(type, path), () => { + return overrideResponse(400, { errors: ['bad request'] }); + }); + await click(SES.configTab); + assert.dom(SES.error.title).hasText('Error', 'shows the secrets backend error route'); + }); + }); }); }); diff --git a/ui/tests/acceptance/secrets/backend/azure/azure-configuration-test.js b/ui/tests/acceptance/secrets/backend/azure/azure-configuration-test.js index b67fd22ce5..3f76a36639 100644 --- a/ui/tests/acceptance/secrets/backend/azure/azure-configuration-test.js +++ b/ui/tests/acceptance/secrets/backend/azure/azure-configuration-test.js @@ -89,7 +89,7 @@ module('Acceptance | Azure | configuration', function (hooks) { environment: 'AZUREPUBLICCLOUD', }; this.server.get(`${path}/config`, () => { - assert.ok(true, 'request made to config when navigating to the configuration page.'); + assert.true(true, 'request made to config when navigating to the configuration page.'); return { data: { id: path, type: this.type, ...azureAccountAttrs } }; }); await enablePage.enable(this.type, path); @@ -109,17 +109,6 @@ module('Acceptance | Azure | configuration', function (hooks) { // cleanup await runCmd(`delete sys/mounts/${path}`); }); - - test('it should show API error when configuration read fails', async function (assert) { - assert.expect(1); - const path = `azure-${this.uid}`; - // interrupt get and return API error - this.server.get(configUrl(this.type, path), () => { - return overrideResponse(400, { errors: ['bad request'] }); - }); - await enablePage.enable(this.type, path); - assert.dom(SES.error.title).hasText('Error', 'shows the secrets backend error route'); - }); }); module('create', function () { @@ -129,9 +118,8 @@ module('Acceptance | Azure | configuration', function (hooks) { await enablePage.enable(this.type, path); this.server.post('/identity/oidc/config', () => { - assert.notOk( - true, - 'post request was made to issuer endpoint when on community and data not changed. test should fail.' + throw new Error( + `Request was made to return the issuer when it should not have been because user is on CE.` ); }); @@ -226,6 +214,42 @@ module('Acceptance | Azure | configuration', function (hooks) { await runCmd(`delete sys/mounts/${path}`); }); }); + + module('Error handling', function () { + test('it prevents transition and shows api error if config errored on save', async function (assert) { + const path = `azure-${this.uid}`; + await enablePage.enable('azure', path); + + this.server.post(configUrl('azure', path), () => { + return overrideResponse(400, { errors: ['welp, that did not work!'] }); + }); + + await click(SES.configTab); + await click(SES.configure); + await fillInAzureConfig('azure'); + await click(GENERAL.saveButton); + + assert.dom(GENERAL.messageError).hasText('Error welp, that did not work!', 'API error shows on form'); + assert.strictEqual( + currentURL(), + `/vault/secrets/${path}/configuration/edit`, + 'the form did not transition because the save failed.' + ); + // cleanup + await runCmd(`delete sys/mounts/${path}`); + }); + + test('it should show API error when configuration read fails', async function (assert) { + assert.expect(1); + const path = `azure-${this.uid}`; + // interrupt get and return API error + this.server.get(configUrl(this.type, path), () => { + return overrideResponse(400, { errors: ['bad request'] }); + }); + await enablePage.enable(this.type, path); + assert.dom(SES.error.title).hasText('Error', 'shows the secrets backend error route'); + }); + }); }); module('isEnterprise', function (hooks) { @@ -245,7 +269,7 @@ module('Acceptance | Azure | configuration', function (hooks) { environment: 'AZUREPUBLICCLOUD', }; this.server.get(`${path}/config`, () => { - assert.ok(true, 'request made to config when navigating to the configuration page.'); + assert.true(true, 'request made to config when navigating to the configuration page.'); return { data: { id: path, type: this.type, ...wifAttrs } }; }); await enablePage.enable(this.type, path); @@ -268,11 +292,11 @@ module('Acceptance | Azure | configuration', function (hooks) { const path = `azure-${this.uid}`; this.server.get(`${path}/config`, (schema, req) => { const payload = JSON.parse(req.requestBody); - assert.ok(true, 'request made to config/root when navigating to the configuration page.'); + assert.true(true, 'request made to config/root when navigating to the configuration page.'); return { data: { id: path, type: this.type, attributes: payload } }; }); this.server.get(`identity/oidc/config`, () => { - assert.notOk(true, 'request made to return issuer. test should fail.'); + throw new Error(`Request was made to return the issuer when it should not have been.`); }); await createConfig(this.store, path, this.type); // create the azure account config in the store await enablePage.enable(this.type, path); diff --git a/ui/tests/acceptance/secrets/backend/gcp/gcp-configuration-test.js b/ui/tests/acceptance/secrets/backend/gcp/gcp-configuration-test.js index 323285421e..460be68b48 100644 --- a/ui/tests/acceptance/secrets/backend/gcp/gcp-configuration-test.js +++ b/ui/tests/acceptance/secrets/backend/gcp/gcp-configuration-test.js @@ -72,7 +72,7 @@ module('Acceptance | GCP | configuration', function (hooks) { ttl: 3600, }; this.server.get(`${path}/config`, () => { - assert.ok(true, 'request made to config when navigating to the configuration page.'); + assert.true(true, 'request made to config when navigating to the configuration page.'); return { data: { id: path, type: this.type, ...wifAttrs } }; }); await enablePage.enable(this.type, path); @@ -99,7 +99,7 @@ module('Acceptance | GCP | configuration', function (hooks) { max_ttl: '4 hours', }; this.server.get(`${path}/config`, () => { - assert.ok(true, 'request made to config when navigating to the configuration page.'); + assert.true(true, 'request made to config when navigating to the configuration page.'); return { data: { id: path, type: this.type, ...GCPAccountAttrs } }; }); await enablePage.enable(this.type, path); diff --git a/ui/tests/helpers/secret-engine/secret-engine-selectors.ts b/ui/tests/helpers/secret-engine/secret-engine-selectors.ts index eeb091e95b..2b49dad260 100644 --- a/ui/tests/helpers/secret-engine/secret-engine-selectors.ts +++ b/ui/tests/helpers/secret-engine/secret-engine-selectors.ts @@ -6,6 +6,7 @@ export const SECRET_ENGINE_SELECTORS = { configTab: '[data-test-configuration-tab]', configure: '[data-test-secret-backend-configure]', + configureNote: (name: string) => `[data-test-configure-note="${name}"]`, configureTitle: (type: string) => `[data-test-backend-configure-title="${type}"]`, configurationToggle: '[data-test-mount-config-toggle]', createSecret: '[data-test-secret-create]', @@ -24,6 +25,7 @@ export const SECRET_ENGINE_SELECTORS = { viewBackend: '[data-test-backend-view-link]', warning: '[data-test-warning]', configureForm: '[data-test-configure-form]', + additionalConfigModelTitle: '[data-test-additional-config-model-title]', wif: { accessTypeSection: '[data-test-access-type-section]', accessTitle: '[data-test-access-title]', @@ -35,8 +37,6 @@ export const SECRET_ENGINE_SELECTORS = { issuerWarningSave: '[data-test-issuer-save]', }, aws: { - rootForm: '[data-test-root-form]', - leaseTitle: '[data-test-lease-title]', deleteRole: (role: string) => `[data-test-aws-role-delete="${role}"]`, }, ssh: { diff --git a/ui/tests/integration/components/secret-engine/configure-aws-test.js b/ui/tests/integration/components/secret-engine/configure-aws-test.js deleted file mode 100644 index 13acfe6ff7..0000000000 --- a/ui/tests/integration/components/secret-engine/configure-aws-test.js +++ /dev/null @@ -1,578 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { module, test } from 'qunit'; -import sinon from 'sinon'; -import { setupRenderingTest } from 'vault/tests/helpers'; -import { GENERAL } from 'vault/tests/helpers/general-selectors'; -import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors'; -import { render, click, fillIn } from '@ember/test-helpers'; -import { setupMirage } from 'ember-cli-mirage/test-support'; -import { hbs } from 'ember-cli-htmlbars'; -import { v4 as uuidv4 } from 'uuid'; -import { overrideResponse } from 'vault/tests/helpers/stubs'; -import { - expectedConfigKeys, - createConfig, - configUrl, - fillInAwsConfig, -} from 'vault/tests/helpers/secret-engine/secret-engine-helpers'; -import { capabilitiesStub } from 'vault/tests/helpers/stubs'; - -module('Integration | Component | SecretEngine/ConfigureAws', function (hooks) { - setupRenderingTest(hooks); - setupMirage(hooks); - - hooks.beforeEach(function () { - this.store = this.owner.lookup('service:store'); - this.flashMessages = this.owner.lookup('service:flash-messages'); - this.flashMessages.registerTypes(['success', 'danger']); - this.flashSuccessSpy = sinon.spy(this.flashMessages, 'success'); - this.flashDangerSpy = sinon.spy(this.flashMessages, 'danger'); - this.transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo'); - - this.uid = uuidv4(); - this.id = `aws-${this.uid}`; - // using createRecord on root and lease configs to simulate a fresh mount - this.rootConfig = this.store.createRecord('aws/root-config'); - this.leaseConfig = this.store.createRecord('aws/lease-config'); - // issuer config is never a createdRecord but the response from the API. - this.issuerConfig = createConfig(this.store, this.id, 'issuer'); - // Add backend to the configs because it's not on the testing snapshot (would come from url) - this.rootConfig.backend = this.leaseConfig.backend = this.id; - this.version = this.owner.lookup('service:version'); - // stub capabilities so that by default user can read and update issuer - this.server.post('/sys/capabilities-self', () => capabilitiesStub('identity/oidc/config', ['sudo'])); - - this.renderComponent = () => { - return render(hbs` - - `); - }; - }); - module('Create view', function () { - module('isEnterprise', function (hooks) { - hooks.beforeEach(function () { - this.version.type = 'enterprise'; - }); - - test('it renders fields ', async function (assert) { - await this.renderComponent(); - assert.dom(SES.aws.rootForm).exists('it lands on the aws root configuration form.'); - assert.dom(SES.wif.accessTitle).exists('Access section is rendered'); - assert.dom(SES.aws.leaseTitle).exists('Lease section is rendered'); - assert.dom(SES.wif.accessTypeSection).exists('Access type section is rendered'); - assert.dom(SES.wif.accessType('iam')).isChecked('defaults to showing IAM access type checked'); - assert.dom(SES.wif.accessType('wif')).isNotChecked('wif access type is not checked'); - // check all the form fields are present - await click(GENERAL.toggleGroup('Root config options')); - for (const key of expectedConfigKeys('aws', true)) { - assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for root section.`); - } - for (const key of expectedConfigKeys('aws-lease')) { - assert.dom(`[data-test-ttl-form-label="${key}"]`).exists(`${key} shows for Lease section.`); - } - assert.dom(GENERAL.inputByAttr('issuer')).doesNotExist(); - }); - - test('it renders wif fields when selected', async function (assert) { - await this.renderComponent(); - await click(SES.wif.accessType('wif')); - // check for the wif fields only - for (const key of expectedConfigKeys('aws-wif', true)) { - if (key === 'Identity token TTL') { - assert.dom(GENERAL.ttl.toggle(key)).exists(`${key} shows for wif section.`); - } else { - assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for wif section.`); - } - } - // check iam fields do not show - for (const key of expectedConfigKeys('aws', true)) { - assert.dom(GENERAL.inputByAttr(key)).doesNotExist(`${key} does not show when wif is selected.`); - } - }); - - test('it clears wif/iam inputs after toggling accessType', async function (assert) { - await this.renderComponent(); - await fillInAwsConfig('withAccess'); - await fillInAwsConfig('withLease'); - await click(SES.wif.accessType('wif')); // toggle to wif - await fillInAwsConfig('withWif'); - await click(SES.wif.accessType('iam')); // toggle to wif - assert - .dom(GENERAL.inputByAttr('accessKey')) - .hasValue('', 'accessKey is cleared after toggling accessType'); - assert - .dom(GENERAL.inputByAttr('secretKey')) - .hasValue('', 'secretKey is cleared after toggling accessType'); - - await click(SES.wif.accessType('wif')); - assert - .dom(GENERAL.inputByAttr('issuer')) - .hasValue('', 'issue shows no value after toggling accessType'); - assert - .dom(GENERAL.inputByAttr('issuer')) - .hasAttribute( - 'placeholder', - 'https://vault-test.com', - 'issue shows no value after toggling accessType' - ); - assert - .dom(GENERAL.inputByAttr('roleArn')) - .hasValue('', 'roleArn is cleared after toggling accessType'); - assert - .dom(GENERAL.inputByAttr('identityTokenAudience')) - .hasValue('', 'identityTokenAudience is cleared after toggling accessType'); - assert - .dom(GENERAL.toggleInput('Identity token TTL')) - .isNotChecked('identityTokenTtl is cleared after toggling accessType'); - }); - - test('it does not clear global issuer when toggling accessType', async function (assert) { - this.issuerConfig = createConfig(this.store, this.id, 'issuer'); - await this.renderComponent(); - await click(SES.wif.accessType('wif')); - assert - .dom(GENERAL.inputByAttr('issuer')) - .hasValue(this.issuerConfig.issuer, 'issuer is what is sent in my the model on first load'); - await fillIn(GENERAL.inputByAttr('issuer'), 'http://ive-changed'); - await click(SES.wif.accessType('iam')); - await click(SES.wif.accessType('wif')); - assert - .dom(GENERAL.inputByAttr('issuer')) - .hasValue( - this.issuerConfig.issuer, - 'issuer value is still the same global value after toggling accessType' - ); - }); - - test('it shows validation error if default lease is entered but max lease is not', async function (assert) { - assert.expect(2); - await this.renderComponent(); - this.server.post(configUrl('aws-lease', this.id), () => { - assert.false( - true, - 'post request was made to config/lease when no data was changed. test should fail.' - ); - }); - this.server.post(configUrl('aws', this.id), () => { - assert.false( - true, - 'post request was made to config/root when no data was changed. test should fail.' - ); - }); - await click(GENERAL.ttl.toggle('Default Lease TTL')); - await fillIn(GENERAL.ttl.input('Default Lease TTL'), '33'); - await click(GENERAL.saveButton); - assert - .dom(GENERAL.inlineError) - .hasText('Lease TTL and Max Lease TTL are both required if one of them is set.'); - assert.dom(SES.aws.rootForm).exists('remains on the configuration form'); - }); - - test('it surfaces the API error if one occurs on root/config, preventing user from transitioning', async function (assert) { - assert.expect(3); - await this.renderComponent(); - this.server.post(configUrl('aws', this.id), () => { - return overrideResponse(400, { errors: ['bad request'] }); - }); - this.server.post(configUrl('aws-lease', this.id), () => { - assert.true( - true, - 'post request was made to config/lease when config/root failed. test should pass.' - ); - }); - // fill in both lease and root endpoints to ensure that both payloads are attempted to be sent - await fillInAwsConfig('withAccess'); - await fillInAwsConfig('withLease'); - await click(GENERAL.saveButton); - assert.dom(GENERAL.messageError).exists('API error surfaced to user'); - assert.dom(GENERAL.inlineError).exists('User shown inline error message'); - }); - - test('it allows user to submit root config even if API error occurs on config/lease config', async function (assert) { - assert.expect(3); - await this.renderComponent(); - this.server.post(configUrl('aws', this.id), () => { - assert.true( - true, - 'post request was made to config/root when config/lease failed. test should pass.' - ); - }); - this.server.post(configUrl('aws-lease', this.id), () => { - return overrideResponse(400, { errors: ['bad request'] }); - }); - // fill in both lease and root endpoints to ensure that both payloads are attempted to be sent - await fillInAwsConfig('withAccess'); - await fillInAwsConfig('withLease'); - await click(GENERAL.saveButton); - - assert.true( - this.flashDangerSpy.calledWith('Lease configuration was not saved: bad request'), - 'Flash message shows that lease was not saved.' - ); - assert.ok( - this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id), - 'Transitioned to the configuration index route.' - ); - }); - - test('it allows user to submit root config even if API error occurs on issuer config', async function (assert) { - assert.expect(4); - await this.renderComponent(); - this.server.post(configUrl('aws', this.id), () => { - assert.true(true, 'post request was made to config/root when issuer failed. test should pass.'); - }); - this.server.post('/identity/oidc/config', () => { - return overrideResponse(400, { errors: ['bad request'] }); - }); - await fillInAwsConfig('withWif'); - await click(GENERAL.saveButton); - await click(SES.wif.issuerWarningSave); - - assert.true( - this.flashDangerSpy.calledWith('Issuer was not saved: bad request'), - 'Flash message shows that issuer was not saved' - ); - assert.true( - this.flashSuccessSpy.calledWith(`Successfully saved ${this.id}'s root configuration.`), - 'Flash message shows that root was saved even if issuer was not' - ); - assert.ok( - this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id), - 'Transitioned to the configuration index route.' - ); - }); - - test('it transitions without sending a lease, root, or issuer payload on cancel', async function (assert) { - assert.expect(3); - await this.renderComponent(); - this.server.post(configUrl('aws', this.id), () => { - assert.true( - false, - 'post request was made to config/root when user canceled out of flow. test should fail.' - ); - }); - this.server.post(configUrl('aws-lease', this.id), () => { - assert.true( - false, - 'post request was made to config/lease when user canceled out of flow. test should fail.' - ); - }); - this.server.post('/identity/oidc/config', () => { - assert.true( - false, - 'post request was made to save issuer when user canceled out of flow. test should fail.' - ); - }); - // fill in both lease and root endpoints to ensure that both payloads are attempted to be sent - await fillInAwsConfig('withWif'); - await fillInAwsConfig('withLease'); - await click(GENERAL.cancelButton); - - assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.'); - assert.true(this.flashSuccessSpy.notCalled, 'No success flash messages called.'); - assert.ok( - this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id), - 'Transitioned to the configuration index route.' - ); - }); - - module('issuer field tests', function () { - // the other tests where issuer is not passed do not show modals, so we only need to test when the modal should shows up - test('if issuer API error and user changes issuer value, shows specific warning message', async function (assert) { - this.issuerConfig.queryIssuerError = true; - await this.renderComponent(); - await click(SES.wif.accessType('wif')); - await fillIn(GENERAL.inputByAttr('issuer'), 'http://change.me.no.read'); - await click(GENERAL.saveButton); - assert - .dom(SES.wif.issuerWarningMessage) - .hasText( - `You are updating the global issuer config. This will overwrite Vault's current issuer if it exists and may affect other configurations using this value. Continue?`, - 'modal shows message about overwriting value if it exists' - ); - }); - - test('is shows placeholder issuer, shows modal when saving changes, and does not call APIs on cancel', async function (assert) { - this.server.post('/identity/oidc/config', () => { - assert.notOk(true, 'request should not be made to issuer config endpoint'); - }); - this.server.post(configUrl('aws', this.id), () => { - assert.notOk( - true, - 'post request was made to config/root when user canceled out of flow. test should fail.' - ); - }); - this.server.post(configUrl('aws-lease', this.id), () => { - assert.notOk( - true, - 'post request was made to config/lease when user canceled out of flow. test should fail.' - ); - }); - await this.renderComponent(); - await click(SES.wif.accessType('wif')); - assert - .dom(GENERAL.inputByAttr('issuer')) - .hasAttribute('placeholder', 'https://vault-test.com', 'shows issuer placeholder'); - assert.dom(GENERAL.inputByAttr('issuer')).hasValue('', 'shows issuer is empty when not passed'); - await fillIn(GENERAL.inputByAttr('issuer'), 'http://bar.foo'); - await click(GENERAL.saveButton); - assert.dom(SES.wif.issuerWarningModal).exists('issuer modal exists'); - assert - .dom(SES.wif.issuerWarningMessage) - .hasText( - `You are updating the global issuer config. This will overwrite Vault's current issuer and may affect other configurations using this value. Continue?`, - 'modal shows message about overwriting value without the noRead: "if it exists" adage' - ); - await click(SES.wif.issuerWarningCancel); - assert.dom(SES.wif.issuerWarningModal).doesNotExist('issuer modal is removed on cancel'); - assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.'); - assert.true(this.flashSuccessSpy.notCalled, 'No success flash messages called.'); - assert.true(this.transitionStub.notCalled, 'Does not redirect'); - }); - - test('it shows modal when updating issuer and calls correct APIs on save', async function (assert) { - const newIssuer = 'http://bar.foo'; - this.server.post('/identity/oidc/config', (schema, req) => { - const payload = JSON.parse(req.requestBody); - assert.deepEqual(payload, { issuer: newIssuer }, 'payload for issuer is correct'); - return { - id: 'identity-oidc-config', // id needs to match the id on secret-engine-helpers createIssuerConfig - data: null, - warnings: [ - 'If "issuer" is set explicitly, all tokens must be validated against that address, including those issued by secondary clusters. Setting issuer to "" will restore the default behavior of using the cluster\'s api_addr as the issuer.', - ], - }; - }); - this.server.post(configUrl('aws', this.id), () => { - assert.notOk(true, 'skips request to config/root due to no changes'); - }); - this.server.post(configUrl('aws-lease', this.id), () => { - assert.notOk(true, 'skips request to config/lease due to no changes'); - }); - await this.renderComponent(); - await click(SES.wif.accessType('wif')); - assert.dom(GENERAL.inputByAttr('issuer')).hasValue('', 'issuer defaults to empty string'); - await fillIn(GENERAL.inputByAttr('issuer'), newIssuer); - await click(GENERAL.saveButton); - assert.dom(SES.wif.issuerWarningModal).exists('issue warning modal exists'); - await click(SES.wif.issuerWarningSave); - assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.'); - assert.true( - this.flashSuccessSpy.calledWith('Issuer saved successfully'), - 'Success flash message called for issuer' - ); - assert.ok( - this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id), - 'Transitioned to the configuration index route.' - ); - }); - - test('shows modal when modifying the issuer, has correct payload, and shows flash message on fail', async function (assert) { - assert.expect(7); - this.issuer = 'http://foo.bar'; - this.server.post(configUrl('aws', this.id), () => { - assert.true( - true, - 'post request was made to config/root when unsetting the issuer. test should pass.' - ); - }); - this.server.post('/identity/oidc/config', (_, req) => { - const payload = JSON.parse(req.requestBody); - assert.deepEqual(payload, { issuer: this.issuer }, 'correctly sets the issuer'); - return overrideResponse(403); - }); - - await this.renderComponent(); - await click(SES.wif.accessType('wif')); - assert.dom(GENERAL.inputByAttr('issuer')).hasValue(''); - await fillIn(GENERAL.inputByAttr('issuer'), this.issuer); - await fillIn(GENERAL.inputByAttr('roleArn'), 'some-other-value'); - await click(GENERAL.saveButton); - assert.dom(SES.wif.issuerWarningModal).exists('issuer warning modal exists'); - - await click(SES.wif.issuerWarningSave); - assert.true( - this.flashDangerSpy.calledWith('Issuer was not saved: permission denied'), - 'shows danger flash for issuer save' - ); - assert.true( - this.flashSuccessSpy.calledWith(`Successfully saved ${this.id}'s root configuration.`), - "calls the root flash message not the issuer's" - ); - - assert.ok( - this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id), - 'Transitioned to the configuration index route.' - ); - }); - }); - }); - module('isCommunity', function (hooks) { - hooks.beforeEach(function () { - this.version.type = 'community'; - }); - test('it renders fields', async function (assert) { - assert.expect(13); - await this.renderComponent(); - assert.dom(SES.aws.rootForm).exists('it lands on the aws root configuration form.'); - assert.dom(SES.wif.accessTitle).exists('Access section is rendered'); - assert.dom(SES.aws.leaseTitle).exists('Lease section is rendered'); - assert - .dom(SES.wif.accessTypeSection) - .doesNotExist('Access type section does not render for a community user'); - // check all the form fields are present - await click(GENERAL.toggleGroup('Root config options')); - for (const key of expectedConfigKeys('aws', true)) { - assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for root section.`); - } - for (const key of expectedConfigKeys('aws-lease')) { - assert.dom(`[data-test-ttl-form-label="${key}"]`).exists(`${key} shows for Lease section.`); - } - assert.dom(GENERAL.inputByAttr('issuer')).doesNotExist(); - }); - test('it does not send issuer on save', async function (assert) { - assert.expect(4); - await this.renderComponent(); - this.server.post(configUrl('aws', this.id), () => { - assert.true(true, 'post request was made to config/root. test should pass.'); - }); - this.server.post('/identity/oidc/config', () => { - throw new Error('post request was incorrectly made to update issuer'); - }); - await fillInAwsConfig('withAccess'); - await fillInAwsConfig('withLease'); - await click(GENERAL.saveButton); - assert.dom(SES.wif.issuerWarningModal).doesNotExist('modal should not render'); - assert.true( - this.flashSuccessSpy.calledWith(`Successfully saved ${this.id}'s root configuration.`), - 'Flash message shows that root was saved even if issuer was not' - ); - assert.ok( - this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id), - 'Transitioned to the configuration index route.' - ); - }); - }); - }); - module('Edit view', function (hooks) { - hooks.beforeEach(function () { - this.rootConfig = createConfig(this.store, this.id, 'aws'); - this.leaseConfig = createConfig(this.store, this.id, 'aws-lease'); - }); - module('isEnterprise', function (hooks) { - hooks.beforeEach(function () { - this.version.type = 'enterprise'; - }); - - test('it defaults to IAM accessType if IAM fields are already set', async function (assert) { - await this.renderComponent(); - assert.dom(SES.wif.accessType('iam')).isChecked('IAM accessType is checked'); - assert.dom(SES.wif.accessType('iam')).isDisabled('IAM accessType is disabled'); - assert.dom(SES.wif.accessType('wif')).isNotChecked('WIF accessType is not checked'); - assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled'); - assert - .dom(SES.wif.accessTypeSubtext) - .hasText('You cannot edit Access Type if you have already saved access credentials.'); - }); - - test('it defaults to WIF accessType if WIF fields are already set', async function (assert) { - this.rootConfig = createConfig(this.store, this.id, 'aws-wif'); - await this.renderComponent(); - assert.dom(SES.wif.accessType('wif')).isChecked('WIF accessType is checked'); - assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled'); - assert.dom(SES.wif.accessType('iam')).isNotChecked('IAM accessType is not checked'); - assert.dom(SES.wif.accessType('iam')).isDisabled('IAM accessType is disabled'); - assert.dom(GENERAL.inputByAttr('roleArn')).hasValue(this.rootConfig.roleArn); - assert - .dom(SES.wif.accessTypeSubtext) - .hasText('You cannot edit Access Type if you have already saved access credentials.'); - assert - .dom(GENERAL.inputByAttr('identityTokenAudience')) - .hasValue(this.rootConfig.identityTokenAudience); - assert.dom(GENERAL.ttl.input('Identity token TTL')).hasValue('2'); // 7200 on payload is 2hrs in ttl picker - }); - - test('it renders issuer if global issuer is already set', async function (assert) { - this.rootConfig = createConfig(this.store, this.id, 'aws-wif'); - this.issuerConfig = createConfig(this.store, this.id, 'issuer'); - await this.renderComponent(); - assert.dom(SES.wif.accessType('wif')).isChecked('WIF accessType is checked'); - assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled'); - assert - .dom(GENERAL.inputByAttr('issuer')) - .hasValue(this.issuerConfig.issuer, 'it has the models issuer value'); - }); - - test('it allows you to change access type if record does not have wif or iam values already set', async function (assert) { - // the model does not have to be new for a user to see the option to change the access type. - // the access type is only disabled if the model has values already set for access type fields. - this.rootConfig = createConfig(this.store, this.id, 'aws-no-access'); - await this.renderComponent(); - assert.dom(SES.wif.accessType('wif')).isNotDisabled('WIF accessType is NOT disabled'); - assert.dom(SES.wif.accessType('iam')).isNotDisabled('IAM accessType is NOT disabled'); - }); - - test('it shows previously saved root and lease information', async function (assert) { - await this.renderComponent(); - assert.dom(GENERAL.inputByAttr('accessKey')).hasValue(this.rootConfig.accessKey); - assert - .dom(GENERAL.inputByAttr('secretKey')) - .hasValue('**********', 'secretKey is masked on edit the value'); - - await click(GENERAL.toggleGroup('Root config options')); - assert.dom(GENERAL.inputByAttr('region')).hasValue(this.rootConfig.region); - assert.dom(GENERAL.inputByAttr('iamEndpoint')).hasValue(this.rootConfig.iamEndpoint); - assert.dom(GENERAL.inputByAttr('stsEndpoint')).hasValue(this.rootConfig.stsEndpoint); - assert.dom(GENERAL.inputByAttr('maxRetries')).hasValue('1'); - // Check lease config values - assert.dom(GENERAL.ttl.input('Default Lease TTL')).hasValue('50'); - assert.dom(GENERAL.ttl.input('Max Lease TTL')).hasValue('55'); - }); - - test('it requires a double click to change the secret key', async function (assert) { - await this.renderComponent(); - - this.server.post(configUrl('aws', this.id), (schema, req) => { - const payload = JSON.parse(req.requestBody); - assert.strictEqual( - payload.secret_key, - 'new-secret', - 'post request was made to config/root with the updated secret_key.' - ); - }); - - await click(GENERAL.enableField('secretKey')); - await click('[data-test-button="toggle-masked"]'); - await fillIn(GENERAL.inputByAttr('secretKey'), 'new-secret'); - await click(GENERAL.saveButton); - }); - }); - module('isCommunity', function (hooks) { - hooks.beforeEach(function () { - this.version.type = 'community'; - }); - - test('it does not show access types but defaults to iam fields', async function (assert) { - await this.renderComponent(); - assert.dom(SES.wif.accessTypeSection).doesNotExist('Access type section does not render'); - assert.dom(GENERAL.inputByAttr('accessKey')).hasValue(this.rootConfig.accessKey); - assert - .dom(GENERAL.inputByAttr('secretKey')) - .hasValue('**********', 'secretKey is masked on edit the value'); - - await click(GENERAL.toggleGroup('Root config options')); - assert.dom(GENERAL.inputByAttr('region')).hasValue(this.rootConfig.region); - assert.dom(GENERAL.inputByAttr('iamEndpoint')).hasValue(this.rootConfig.iamEndpoint); - assert.dom(GENERAL.inputByAttr('stsEndpoint')).hasValue(this.rootConfig.stsEndpoint); - assert.dom(GENERAL.inputByAttr('maxRetries')).hasValue('1'); - // Check lease config values - assert.dom(GENERAL.ttl.input('Default Lease TTL')).hasValue('50'); - assert.dom(GENERAL.ttl.input('Max Lease TTL')).hasValue('55'); - }); - }); - }); -}); diff --git a/ui/tests/integration/components/secret-engine/configure-azure-test.js b/ui/tests/integration/components/secret-engine/configure-azure-test.js deleted file mode 100644 index 07b3fab00b..0000000000 --- a/ui/tests/integration/components/secret-engine/configure-azure-test.js +++ /dev/null @@ -1,426 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { module, test } from 'qunit'; -import sinon from 'sinon'; -import { setupRenderingTest } from 'vault/tests/helpers'; -import { GENERAL } from 'vault/tests/helpers/general-selectors'; -import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors'; -import { render, click, fillIn } from '@ember/test-helpers'; -import { setupMirage } from 'ember-cli-mirage/test-support'; -import { hbs } from 'ember-cli-htmlbars'; -import { v4 as uuidv4 } from 'uuid'; -import { overrideResponse } from 'vault/tests/helpers/stubs'; -import { - expectedConfigKeys, - createConfig, - configUrl, - fillInAzureConfig, -} from 'vault/tests/helpers/secret-engine/secret-engine-helpers'; -import { capabilitiesStub } from 'vault/tests/helpers/stubs'; - -module('Integration | Component | SecretEngine/ConfigureAzure', function (hooks) { - setupRenderingTest(hooks); - setupMirage(hooks); - - hooks.beforeEach(function () { - this.store = this.owner.lookup('service:store'); - this.version = this.owner.lookup('service:version'); - this.flashMessages = this.owner.lookup('service:flash-messages'); - this.flashMessages.registerTypes(['success', 'danger']); - this.flashSuccessSpy = sinon.spy(this.flashMessages, 'success'); - this.flashDangerSpy = sinon.spy(this.flashMessages, 'danger'); - this.transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo'); - - this.uid = uuidv4(); - this.id = `azure-${this.uid}`; - this.config = this.store.createRecord('azure/config'); - this.issuerConfig = createConfig(this.store, this.id, 'issuer'); - this.config.backend = this.id; // Add backend to the configs because it's not on the testing snapshot (would come from url) - // stub capabilities so that by default user can read and update issuer - this.server.post('/sys/capabilities-self', () => capabilitiesStub('identity/oidc/config', ['sudo'])); - - this.renderComponent = () => { - return render(hbs` - - `); - }; - }); - module('Create view', function () { - module('isEnterprise', function (hooks) { - hooks.beforeEach(function () { - this.version.type = 'enterprise'; - }); - - test('it renders default fields, showing access type options for enterprise users', async function (assert) { - await this.renderComponent(); - assert.dom(SES.configureForm).exists('it lands on the Azure configuration form.'); - assert.dom(SES.wif.accessType('azure')).isChecked('defaults to showing Azure access type checked'); - assert.dom(SES.wif.accessType('wif')).isNotChecked('wif access type is not checked'); - // check all the form fields are present - for (const key of expectedConfigKeys('azure', true)) { - assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for root section`); - } - assert.dom(GENERAL.inputByAttr('issuer')).doesNotExist(); - }); - - test('it renders wif fields when user selects wif access type', async function (assert) { - await this.renderComponent(); - await click(SES.wif.accessType('wif')); - // check for the wif fields only - for (const key of expectedConfigKeys('azure-wif', true)) { - if (key === 'Identity token TTL') { - assert.dom(GENERAL.ttl.toggle(key)).exists(`${key} shows for wif section.`); - } else { - assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for wif section.`); - } - } - assert.dom(GENERAL.inputByAttr('issuer')).exists('issuer shows for wif section.'); - }); - - test('it clears wif/azure-account inputs after toggling accessType', async function (assert) { - await this.renderComponent(); - await fillInAzureConfig('azure'); - await click(SES.wif.accessType('wif')); - await fillInAzureConfig('withWif'); - await click(SES.wif.accessType('azure')); - - assert - .dom(GENERAL.toggleInput('Root password TTL')) - .isNotChecked('rootPasswordTtl is cleared after toggling accessType'); - assert - .dom(GENERAL.inputByAttr('clientSecret')) - .hasValue('', 'clientSecret is cleared after toggling accessType'); - await click(SES.wif.accessType('wif')); - assert - .dom(GENERAL.inputByAttr('issuer')) - .hasValue('', 'issuer shows no value after toggling accessType'); - assert - .dom(GENERAL.inputByAttr('issuer')) - .hasAttribute( - 'placeholder', - 'https://vault-test.com', - 'issuer shows no value after toggling accessType' - ); - assert - .dom(GENERAL.inputByAttr('identityTokenAudience')) - .hasValue('', 'idTokenAudience is cleared after toggling accessType'); - assert - .dom(GENERAL.toggleInput('Identity token TTL')) - .isNotChecked('identityTokenTtl is cleared after toggling accessType'); - }); - - test('it does not clear global issuer when toggling accessType', async function (assert) { - this.issuerConfig = createConfig(this.store, this.id, 'issuer'); - await this.renderComponent(); - await click(SES.wif.accessType('wif')); - assert - .dom(GENERAL.inputByAttr('issuer')) - .hasValue(this.issuerConfig.issuer, 'issuer is what is sent in by the model on first load'); - await fillIn(GENERAL.inputByAttr('issuer'), 'http://ive-changed'); - await click(SES.wif.accessType('azure')); - await click(SES.wif.accessType('wif')); - assert - .dom(GENERAL.inputByAttr('issuer')) - .hasValue( - this.issuerConfig.issuer, - 'issuer value is still the same global value after toggling accessType' - ); - }); - - test('it transitions without sending a config or issuer payload on cancel', async function (assert) { - assert.expect(3); - await this.renderComponent(); - this.server.post(configUrl('azure', this.id), () => { - assert.notOk( - true, - 'post request was made to config when user canceled out of flow. test should fail.' - ); - }); - this.server.post('/identity/oidc/config', () => { - assert.notOk( - true, - 'post request was made to save issuer when user canceled out of flow. test should fail.' - ); - }); - await fillInAzureConfig('withWif'); - await click(GENERAL.cancelButton); - - assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.'); - assert.true(this.flashSuccessSpy.notCalled, 'No success flash messages called.'); - - assert.ok( - this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id), - 'Transitioned to the configuration index route.' - ); - }); - - module('issuer field tests', function () { - test('if issuer API error and user changes issuer value, shows specific warning message', async function (assert) { - this.issuerConfig.queryIssuerError = true; - await this.renderComponent(); - await click(SES.wif.accessType('wif')); - await fillIn(GENERAL.inputByAttr('issuer'), 'http://change.me.no.read'); - await click(GENERAL.saveButton); - assert - .dom(SES.wif.issuerWarningMessage) - .hasText( - `You are updating the global issuer config. This will overwrite Vault's current issuer if it exists and may affect other configurations using this value. Continue?`, - 'modal shows message about overwriting value if it exists' - ); - }); - - test('is shows placeholder issuer, and does not call APIs on canceling out of issuer modal', async function (assert) { - this.server.post('/identity/oidc/config', () => { - assert.notOk(true, 'request should not be made to issuer config endpoint'); - }); - this.server.post(configUrl('azure', this.id), () => { - assert.notOk( - true, - 'post request was made to config/ when user canceled out of flow. test should fail.' - ); - }); - await this.renderComponent(); - await click(SES.wif.accessType('wif')); - assert - .dom(GENERAL.inputByAttr('issuer')) - .hasAttribute('placeholder', 'https://vault-test.com', 'shows issuer placeholder'); - assert.dom(GENERAL.inputByAttr('issuer')).hasValue('', 'shows issuer is empty when not passed'); - await fillIn(GENERAL.inputByAttr('issuer'), 'http://bar.foo'); - await click(GENERAL.saveButton); - assert.dom(SES.wif.issuerWarningMessage).exists('issuer modal exists'); - assert - .dom(SES.wif.issuerWarningMessage) - .hasText( - `You are updating the global issuer config. This will overwrite Vault's current issuer and may affect other configurations using this value. Continue?`, - 'modal shows message about overwriting value without the noRead: "if it exists" adage' - ); - await click(SES.wif.issuerWarningCancel); - assert.dom(SES.wif.issuerWarningMessage).doesNotExist('issuer modal is removed on cancel'); - assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.'); - assert.true(this.flashSuccessSpy.notCalled, 'No success flash messages called.'); - assert.true(this.transitionStub.notCalled, 'Does not redirect'); - }); - - test('it shows modal when updating issuer and calls correct APIs on save', async function (assert) { - const newIssuer = `http://bar.${uuidv4()}`; - this.server.post('/identity/oidc/config', (schema, req) => { - const payload = JSON.parse(req.requestBody); - assert.deepEqual(payload, { issuer: newIssuer }, 'payload for issuer is correct'); - return { - id: 'identity-oidc-config', // id needs to match the id on secret-engine-helpers createIssuerConfig - data: null, - warnings: [ - 'If "issuer" is set explicitly, all tokens must be validated against that address, including those issued by secondary clusters. Setting issuer to "" will restore the default behavior of using the cluster\'s api_addr as the issuer.', - ], - }; - }); - this.server.post(configUrl('azure', this.id), () => { - assert.notOk(true, 'skips request to config because the model was not changed'); - }); - await this.renderComponent(); - await click(SES.wif.accessType('wif')); - assert.dom(GENERAL.inputByAttr('issuer')).hasValue('', 'issuer defaults to empty string'); - await fillIn(GENERAL.inputByAttr('issuer'), newIssuer); - await click(GENERAL.saveButton); - - assert.dom(SES.wif.issuerWarningMessage).exists('issue warning modal exists'); - - await click(SES.wif.issuerWarningSave); - assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.'); - assert.true( - this.flashSuccessSpy.calledWith('Issuer saved successfully'), - 'Success flash message called for Azure issuer' - ); - assert.true( - this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id), - 'Transitioned to the configuration index route.' - ); - }); - - test('shows modal when modifying the issuer, has correct payload, and shows flash message on fail', async function (assert) { - assert.expect(7); - this.issuer = 'http://foo.bar'; - this.server.post(configUrl('azure', this.id), () => { - assert.true( - true, - 'post request was made to azure config when unsetting the issuer. test should pass.' - ); - }); - this.server.post('/identity/oidc/config', (_, req) => { - const payload = JSON.parse(req.requestBody); - assert.deepEqual(payload, { issuer: this.issuer }, 'correctly sets the issuer'); - return overrideResponse(403); - }); - - await this.renderComponent(); - await click(SES.wif.accessType('wif')); - assert.dom(GENERAL.inputByAttr('issuer')).hasValue(''); - await fillIn(GENERAL.inputByAttr('issuer'), this.issuer); - await fillIn(GENERAL.inputByAttr('identityTokenAudience'), 'some-value'); - await click(GENERAL.saveButton); - assert.dom(SES.wif.issuerWarningMessage).exists('issuer warning modal exists'); - await click(SES.wif.issuerWarningSave); - - assert.true( - this.flashDangerSpy.calledWith('Issuer was not saved: permission denied'), - 'shows danger flash for issuer save' - ); - assert.true( - this.flashSuccessSpy.calledWith(`Successfully saved ${this.id}'s configuration.`), - "calls the config flash message not the issuer's" - ); - assert.ok( - this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id), - 'Transitioned to the configuration index route.' - ); - }); - }); - }); - module('isCommunity', function (hooks) { - hooks.beforeEach(function () { - this.version.type = 'community'; - }); - - test('it renders fields', async function (assert) { - assert.expect(9); - await this.renderComponent(); - assert.dom(SES.configureForm).exists('t lands on the Azure configuration form'); - assert - .dom(SES.wif.accessTypeSection) - .doesNotExist('Access type section does not render for a community user'); - // check all the form fields are present - for (const key of expectedConfigKeys('azure', true)) { - assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for azure account creds section.`); - } - assert.dom(GENERAL.inputByAttr('issuer')).doesNotExist(); - }); - - test('it does not send issuer on save', async function (assert) { - assert.expect(4); - await this.renderComponent(); - this.server.post(configUrl('azure', this.id), () => { - assert.true(true, 'post request was made to config. test should pass.'); - }); - this.server.post('/identity/oidc/config', () => { - throw new Error('post request was incorrectly made to update issuer'); - }); - await fillInAzureConfig('azure'); - await click(GENERAL.saveButton); - assert.dom(SES.wif.issuerWarningMessage).doesNotExist('modal should not render'); - assert.true( - this.flashSuccessSpy.calledWith(`Successfully saved ${this.id}'s configuration.`), - 'Flash message shows that config was saved even if issuer was not.' - ); - assert.ok( - this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id), - 'Transitioned to the configuration index route.' - ); - }); - }); - }); - - module('Edit view', function () { - module('isEnterprise', function (hooks) { - hooks.beforeEach(function () { - this.version.type = 'enterprise'; - }); - - test('it defaults to Azure accessType if Azure account fields are already set', async function (assert) { - this.config = createConfig(this.store, this.id, 'azure'); - await this.renderComponent(); - assert.dom(SES.wif.accessType('azure')).isChecked('Azure accessType is checked'); - assert.dom(SES.wif.accessType('azure')).isDisabled('Azure accessType is disabled'); - assert.dom(SES.wif.accessType('wif')).isNotChecked('WIF accessType is not checked'); - assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled'); - assert - .dom(SES.wif.accessTypeSubtext) - .hasText('You cannot edit Access Type if you have already saved access credentials.'); - }); - - test('it defaults to WIF accessType if WIF fields are already set', async function (assert) { - this.config = createConfig(this.store, this.id, 'azure-wif'); - await this.renderComponent(); - assert.dom(SES.wif.accessType('wif')).isChecked('WIF accessType is checked'); - assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled'); - assert.dom(SES.wif.accessType('azure')).isNotChecked('azure accessType is not checked'); - assert.dom(SES.wif.accessType('azure')).isDisabled('azure accessType is disabled'); - assert.dom(GENERAL.inputByAttr('identityTokenAudience')).hasValue(this.config.identityTokenAudience); - assert - .dom(SES.wif.accessTypeSubtext) - .hasText('You cannot edit Access Type if you have already saved access credentials.'); - assert.dom(GENERAL.ttl.input('Identity token TTL')).hasValue('2'); // 7200 on payload is 2hrs in ttl picker - }); - - test('it renders issuer if global issuer is already set', async function (assert) { - this.config = createConfig(this.store, this.id, 'azure-wif'); - this.issuerConfig = createConfig(this.store, this.id, 'issuer'); - this.issuerConfig.issuer = 'https://foo-bar-blah.com'; - await this.renderComponent(); - assert.dom(SES.wif.accessType('wif')).isChecked('WIF accessType is checked'); - assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled'); - assert - .dom(GENERAL.inputByAttr('issuer')) - .hasValue( - this.issuerConfig.issuer, - `it has the global issuer value of ${this.issuerConfig.issuer}` - ); - }); - - test('it allows you to change accessType if record does not have wif or azure values already set', async function (assert) { - // the model does not have to be new for a user to see the option to change the access type. - // the access type is only disabled if the model has values already set for access type fields. - this.config = createConfig(this.store, this.id, 'azure-generic'); - await this.renderComponent(); - assert.dom(SES.wif.accessType('wif')).isNotDisabled('WIF accessType is NOT disabled'); - assert.dom(SES.wif.accessType('azure')).isNotDisabled('Azure accessType is NOT disabled'); - }); - - test('it shows previously saved config information', async function (assert) { - this.config = createConfig(this.store, this.id, 'azure-generic'); - await this.renderComponent(); - assert.dom(GENERAL.inputByAttr('subscriptionId')).hasValue(this.config.subscriptionId); - assert.dom(GENERAL.inputByAttr('clientId')).hasValue(this.config.clientId); - assert.dom(GENERAL.inputByAttr('tenantId')).hasValue(this.config.tenantId); - assert - .dom(GENERAL.inputByAttr('clientSecret')) - .hasValue('**********', 'clientSecret is masked on edit the value'); - }); - - test('it requires a double click to change the client secret', async function (assert) { - this.config = createConfig(this.store, this.id, 'azure'); - await this.renderComponent(); - - this.server.post(configUrl('azure', this.id), (schema, req) => { - const payload = JSON.parse(req.requestBody); - assert.strictEqual( - payload.client_secret, - 'new-secret', - 'post request was made to azure/config with the updated client_secret.' - ); - }); - - await click(GENERAL.enableField('clientSecret')); - await click('[data-test-button="toggle-masked"]'); - await fillIn(GENERAL.inputByAttr('clientSecret'), 'new-secret'); - await click(GENERAL.saveButton); - }); - }); - module('isCommunity', function (hooks) { - hooks.beforeEach(function () { - this.version.type = 'community'; - }); - - test('it does not show access types but defaults to Azure account fields', async function (assert) { - this.config = createConfig(this.store, this.id, 'azure-generic'); - await this.renderComponent(); - assert.dom(SES.wif.accessTypeSection).doesNotExist('Access type section does not render'); - assert.dom(GENERAL.inputByAttr('clientId')).hasValue(this.config.clientId); - assert.dom(GENERAL.inputByAttr('subscriptionId')).hasValue(this.config.subscriptionId); - assert.dom(GENERAL.inputByAttr('tenantId')).hasValue(this.config.tenantId); - }); - }); - }); -}); diff --git a/ui/tests/integration/components/secret-engine/configure-wif-test.js b/ui/tests/integration/components/secret-engine/configure-wif-test.js new file mode 100644 index 0000000000..6126fb3e66 --- /dev/null +++ b/ui/tests/integration/components/secret-engine/configure-wif-test.js @@ -0,0 +1,866 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import sinon from 'sinon'; +import { setupRenderingTest } from 'vault/tests/helpers'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors'; +import { render, click, fillIn } from '@ember/test-helpers'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { v4 as uuidv4 } from 'uuid'; +import { hbs } from 'ember-cli-htmlbars'; +import { overrideResponse } from 'vault/tests/helpers/stubs'; +import { + expectedConfigKeys, + createConfig, + configUrl, + fillInAzureConfig, + fillInAwsConfig, +} from 'vault/tests/helpers/secret-engine/secret-engine-helpers'; +import { capabilitiesStub } from 'vault/tests/helpers/stubs'; +import { WIF_ENGINES, allEngines } from 'vault/helpers/mountable-secret-engines'; +import waitForError from 'vault/tests/helpers/wait-for-error'; + +const allEnginesArray = allEngines(); // saving as const so we don't invoke the method multiple times in the for loop + +module('Integration | Component | SecretEngine::ConfigureWif', function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.store = this.owner.lookup('service:store'); + this.version = this.owner.lookup('service:version'); + this.flashMessages = this.owner.lookup('service:flash-messages'); + this.flashMessages.registerTypes(['success', 'danger']); + this.flashSuccessSpy = sinon.spy(this.flashMessages, 'success'); + this.flashDangerSpy = sinon.spy(this.flashMessages, 'danger'); + this.transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo'); + this.uid = uuidv4(); + // stub capabilities so that by default user can read and update issuer + this.server.post('/sys/capabilities-self', () => capabilitiesStub('identity/oidc/config', ['sudo'])); + }); + + module('Create view', function () { + module('isEnterprise', function (hooks) { + hooks.beforeEach(function () { + this.version.type = 'enterprise'; + }); + + for (const type of WIF_ENGINES) { + test(`${type}: it renders default fields`, async function (assert) { + this.id = `${type}-${this.uid}`; + this.displayName = allEnginesArray.find((engine) => engine.type === type)?.displayName; + this.issuerConfig = createConfig(this.store, this.id, 'issuer'); + this.mountConfigModel = + type === 'aws' + ? this.store.createRecord('aws/root-config') + : this.store.createRecord(`${type}/config`); + this.additionalConfigModel = type === 'aws' ? this.store.createRecord('aws/lease-config') : null; + this.mountConfigModel.backend = this.id; + this.additionalConfigModel ? (this.additionalConfigModel.backend = this.id) : null; // Add backend to the configs because it's not on the testing snapshot (would come from url) + this.type = type; + + await render(hbs` + + `); + assert.dom(SES.configureForm).exists(`it lands on the ${type} configuration form`); + assert.dom(SES.wif.accessType(type)).isChecked(`defaults to showing ${type} access type checked`); + assert.dom(SES.wif.accessType('wif')).isNotChecked('wif access type is not checked'); + // toggle grouped fields if it exists + const toggleGroup = document.querySelector('[data-test-toggle-group]'); + toggleGroup ? await click(toggleGroup) : null; + + for (const key of expectedConfigKeys(type, true)) { + assert + .dom(GENERAL.inputByAttr(key)) + .exists( + `${key} shows for ${type} configuration create section when wif is not the access type` + ); + } + assert + .dom(GENERAL.inputByAttr('issuer')) + .doesNotExist(`for ${type}, the issuer does not show when wif is not the access type`); + }); + } + + for (const type of WIF_ENGINES) { + test(`${type}: it renders wif fields when user selects wif access type`, async function (assert) { + this.id = `${type}-${this.uid}`; + this.displayName = allEnginesArray.find((engine) => engine.type === type)?.displayName; + this.issuerConfig = createConfig(this.store, this.id, 'issuer'); + this.mountConfigModel = + type === 'aws' + ? this.store.createRecord('aws/root-config') + : this.store.createRecord(`${type}/config`); + this.additionalConfigModel = type === 'aws' ? this.store.createRecord('aws/lease-config') : null; + this.mountConfigModel.backend = this.id; + this.additionalConfigModel ? (this.additionalConfigModel.backend = this.id) : null; + this.type = type; + + await render(hbs` + + `); + await click(SES.wif.accessType('wif')); + // check for the wif fields only + for (const key of expectedConfigKeys(`${type}-wif`, true)) { + if (key === 'Identity token TTL') { + assert.dom(GENERAL.ttl.toggle(key)).exists(`${key} shows for ${type} wif section.`); + } else { + assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for ${type} wif section.`); + } + } + assert.dom(GENERAL.inputByAttr('issuer')).exists(`issuer shows for ${type} wif section.`); + }); + } + /* This module covers code that is the same for all engines. We run them once against one of the engines.*/ + module('Engine agnostic', function () { + test('it transitions without sending a config or issuer payload on cancel', async function (assert) { + assert.expect(3); + this.id = `azure-${this.uid}`; + this.displayName = 'Azure'; + this.issuerConfig = createConfig(this.store, this.id, 'issuer'); + this.mountConfigModel = this.store.createRecord('azure/config'); + this.mountConfigModel.backend = this.id; + + await render(hbs` + + `); + this.server.post(configUrl('azure', this.id), () => { + throw new Error( + `Request was made to post the config when it should not have been because the user canceled out of the flow.` + ); + }); + this.server.post('/identity/oidc/config', () => { + throw new Error( + `Request was made to save the issuer when it should not have been because the user canceled out of the flow.` + ); + }); + await fillInAzureConfig('withWif'); + await click(GENERAL.cancelButton); + + assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.'); + assert.true(this.flashSuccessSpy.notCalled, 'No success flash messages called.'); + + assert.true( + this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id), + 'Transitioned to the configuration index route.' + ); + }); + + test('it throws an error if the getter isWifPluginConfigured is not defined on the model', async function (assert) { + const promise = waitForError(); + this.id = `azure-${this.uid}`; + this.displayName = 'Azure'; + // creating a config that exists but will not have the attribute isWifPluginConfigured on it + this.mountConfigModel = this.store.createRecord('ssh/ca-config', { backend: this.id }); + await render(hbs` + + `); + const err = await promise; + assert.true( + err.message.includes( + `'isWifPluginConfigured' is required to be defined on the config model. Must return a boolean.` + ), + 'asserts without isWifPluginConfigured' + ); + }); + + test('it allows user to submit the config even if API error occurs on issuer config', async function (assert) { + this.id = `aws-${this.uid}`; + this.displayName = 'AWS'; + this.issuerConfig = createConfig(this.store, this.id, 'issuer'); + this.mountConfigModel = this.store.createRecord('aws/root-config'); + this.additionalConfigModel = this.store.createRecord('aws/lease-config'); + this.mountConfigModel.backend = this.additionalConfigModel.backend = this.id; + + await render(hbs` + + `); + this.server.post(configUrl('aws', this.id), () => { + assert.true(true, 'post request was made to config/root when issuer failed. test should pass.'); + }); + this.server.post('/identity/oidc/config', () => { + return overrideResponse(400, { errors: ['bad request'] }); + }); + await fillInAwsConfig('withWif'); + await click(GENERAL.saveButton); + await click(SES.wif.issuerWarningSave); + + assert.true( + this.flashDangerSpy.calledWith('Issuer was not saved: bad request'), + 'Flash message shows that issuer was not saved' + ); + assert.true( + this.flashSuccessSpy.calledWith(`Successfully saved ${this.id}'s configuration.`), + 'Flash message shows that root was saved even if issuer was not' + ); + assert.true( + this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id), + 'Transitioned to the configuration index route.' + ); + }); + + test('it surfaces the API error if config save fails, and prevents the user from transitioning', async function (assert) { + this.id = `aws-${this.uid}`; + this.displayName = 'AWS'; + this.issuerConfig = createConfig(this.store, this.id, 'issuer'); + this.mountConfigModel = this.store.createRecord('aws/root-config'); + this.additionalConfigModel = this.store.createRecord('aws/lease-config'); + this.mountConfigModel.backend = this.additionalConfigModel.backend = this.id; + + await render(hbs` + + `); + this.server.post(configUrl('aws', this.id), () => { + return overrideResponse(400, { errors: ['bad request'] }); + }); + this.server.post(configUrl('aws-lease', this.id), () => { + assert.true( + true, + 'post request was made to config/lease when config/root failed. test should pass.' + ); + }); + // fill in both lease and root endpoints to ensure that both payloads are attempted to be sent + await fillInAwsConfig('withAccess'); + await fillInAwsConfig('withLease'); + await click(GENERAL.saveButton); + assert.dom(GENERAL.messageError).exists('API error surfaced to user'); + assert.dom(GENERAL.inlineError).exists('User shown inline error message'); + }); + }); + + module('Azure specific', function (hooks) { + hooks.beforeEach(function () { + this.id = `azure-${this.uid}`; + this.displayName = 'Azure'; + this.issuerConfig = createConfig(this.store, this.id, 'issuer'); + this.mountConfigModel = this.store.createRecord('azure/config'); + this.mountConfigModel.backend = this.id; + }); + test('it clears access type inputs after toggling accessType', async function (assert) { + await render(hbs` + + `); + await fillInAzureConfig('azure'); + await click(SES.wif.accessType('wif')); + await fillInAzureConfig('withWif'); + await click(SES.wif.accessType('azure')); + + assert + .dom(GENERAL.toggleInput('Root password TTL')) + .isNotChecked('rootPasswordTtl is cleared after toggling accessType'); + assert + .dom(GENERAL.inputByAttr('clientSecret')) + .hasValue('', 'clientSecret is cleared after toggling accessType'); + + await click(SES.wif.accessType('wif')); + assert + .dom(GENERAL.inputByAttr('issuer')) + .hasValue('', 'issuer shows no value after toggling accessType'); + assert + .dom(GENERAL.inputByAttr('issuer')) + .hasAttribute( + 'placeholder', + 'https://vault-test.com', + 'issuer shows no value after toggling accessType' + ); + assert + .dom(GENERAL.inputByAttr('identityTokenAudience')) + .hasValue('', 'idTokenAudience is cleared after toggling accessType'); + assert + .dom(GENERAL.toggleInput('Identity token TTL')) + .isNotChecked('identityTokenTtl is cleared after toggling accessType'); + }); + + test('it shows the correct access type subtext', async function (assert) { + await render(hbs` + + `); + + assert + .dom(SES.wif.accessTypeSubtext) + .hasText( + 'Choose the way to configure access to Azure. Access can be configured either using Azure account credentials or with the Plugin Workload Identity Federation (WIF).' + ); + }); + + test('it does not show aws specific note', async function (assert) { + await render(hbs` + + `); + + assert + .dom(SES.configureNote('azure')) + .doesNotExist('Note specific to AWS does not show for Azure secret engine when configuring.'); + }); + }); + + module('AWS specific', function (hooks) { + hooks.beforeEach(function () { + this.id = `aws-${this.uid}`; + this.displayName = 'AWS'; + this.issuerConfig = createConfig(this.store, this.id, 'issuer'); + this.mountConfigModel = this.store.createRecord('aws/root-config'); + this.additionalConfigModel = this.store.createRecord('aws/lease-config'); + this.mountConfigModel.backend = this.additionalConfigModel.backend = this.id; + }); + + test('it clears access type inputs after toggling accessType', async function (assert) { + await render(hbs` + + `); + await fillInAwsConfig('aws'); + await click(SES.wif.accessType('wif')); + await fillInAwsConfig('with-wif'); + await click(SES.wif.accessType('aws')); + + assert + .dom(GENERAL.inputByAttr('accessKey')) + .hasValue('', 'accessKey is cleared after toggling accessType'); + + await click(SES.wif.accessType('wif')); + assert + .dom(GENERAL.inputByAttr('issuer')) + .hasValue('', 'issuer shows no value after toggling accessType'); + assert + .dom(GENERAL.inputByAttr('issuer')) + .hasAttribute( + 'placeholder', + 'https://vault-test.com', + 'issuer shows no value after toggling accessType' + ); + assert + .dom(GENERAL.inputByAttr('identityTokenAudience')) + .hasValue('', 'idTokenAudience is cleared after toggling accessType'); + assert + .dom(GENERAL.toggleInput('Identity token TTL')) + .isNotChecked('identityTokenTtl is cleared after toggling accessType'); + }); + + test('it shows the correct access type subtext', async function (assert) { + await render(hbs` + + `); + + assert + .dom(SES.wif.accessTypeSubtext) + .hasText( + 'Choose the way to configure access to AWS. Access can be configured either using IAM access keys or with the Plugin Workload Identity Federation (WIF).' + ); + }); + + test('it shows validation error if default lease is entered but max lease is not', async function (assert) { + await render(hbs` + + `); + this.server.post(configUrl('aws-lease', this.id), () => { + throw new Error( + `Request was made to post the config/lease when it should not have been because no data was changed.` + ); + }); + this.server.post(configUrl('aws', this.id), () => { + throw new Error( + `Request was made to post the config/root when it should not have been because no data was changed.` + ); + }); + await click(GENERAL.ttl.toggle('Default Lease TTL')); + await fillIn(GENERAL.ttl.input('Default Lease TTL'), '33'); + await click(GENERAL.saveButton); + assert + .dom(GENERAL.inlineError) + .hasText('Lease TTL and Max Lease TTL are both required if one of them is set.'); + assert.dom(SES.configureForm).exists('remains on the configuration form'); + }); + + test('it allows user to submit root config even if API error occurs on config/lease config', async function (assert) { + await render(hbs` + + `); + this.server.post(configUrl('aws', this.id), () => { + assert.true( + true, + 'post request was made to config/root when config/lease failed. test should pass.' + ); + }); + this.server.post(configUrl('aws-lease', this.id), () => { + return overrideResponse(400, { errors: ['bad request!!'] }); + }); + // fill in both lease and root endpoints to ensure that both payloads are attempted to be sent + await fillInAwsConfig('withAccess'); + await fillInAwsConfig('withLease'); + await click(GENERAL.saveButton); + assert.true( + this.flashDangerSpy.calledWith('Lease configuration was not saved: bad request!!'), + 'Flash message shows that lease was not saved.' + ); + assert.true( + this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id), + 'Transitioned to the configuration index route.' + ); + }); + + test('it transitions without sending a lease, root, or issuer payload on cancel', async function (assert) { + await render(hbs` + + `); + this.server.post(configUrl('aws', this.id), () => { + throw new Error( + `Request was made to post the config/root when it should not have been because the user canceled out of the flow.` + ); + }); + this.server.post(configUrl('aws-lease', this.id), () => { + throw new Error( + `Request was made to post the config/lease when it should not have been because the user canceled out of the flow.` + ); + }); + this.server.post('/identity/oidc/config', () => { + throw new Error( + `Request was made to post the identity/oidc/config when it should not have been because the user canceled out of the flow.` + ); + }); + // fill in both lease and root endpoints to ensure that both payloads are attempted to be sent + await fillInAwsConfig('withWif'); + await fillInAwsConfig('withLease'); + await click(GENERAL.cancelButton); + + assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.'); + assert.true(this.flashSuccessSpy.notCalled, 'No success flash messages called.'); + assert.true( + this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id), + 'Transitioned to the configuration index route.' + ); + }); + + test('it does show aws specific note', async function (assert) { + await render(hbs` + + `); + + assert.dom(SES.configureNote('aws')).exists('Note specific to AWS does show when configuring.'); + }); + }); + + module('Issuer field tests', function (hooks) { + hooks.beforeEach(function () { + this.id = `azure-${this.uid}`; + this.displayName = 'Azure'; + this.issuerConfig = createConfig(this.store, this.id, 'issuer'); + this.issuerConfig.queryIssuerError = true; + this.mountConfigModel = this.store.createRecord('azure/config'); + this.mountConfigModel.backend = this.id; + }); + test('if issuer API error and user changes issuer value, shows specific warning message', async function (assert) { + await render(hbs` + + `); + await click(SES.wif.accessType('wif')); + await fillIn(GENERAL.inputByAttr('issuer'), 'http://change.me.no.read'); + await click(GENERAL.saveButton); + assert + .dom(SES.wif.issuerWarningMessage) + .hasText( + `You are updating the global issuer config. This will overwrite Vault's current issuer if it exists and may affect other configurations using this value. Continue?`, + 'modal shows message about overwriting value if it exists' + ); + }); + + test('it shows placeholder issuer, and does not call APIs on canceling out of issuer modal', async function (assert) { + this.server.post('/identity/oidc/config', () => { + throw new Error( + 'Request was made to post the identity/oidc/config when it should not have been because user canceled out of the modal.' + ); + }); + this.server.post(configUrl('azure', this.id), () => { + throw new Error( + `Request was made to post the config when it should not have been because the user canceled out of the flow.` + ); + }); + this.issuerConfig.queryIssuerError = false; + await render(hbs` + + `); + await click(SES.wif.accessType('wif')); + assert + .dom(GENERAL.inputByAttr('issuer')) + .hasAttribute('placeholder', 'https://vault-test.com', 'shows issuer placeholder'); + assert.dom(GENERAL.inputByAttr('issuer')).hasValue('', 'shows issuer is empty when not passed'); + await fillIn(GENERAL.inputByAttr('issuer'), 'http://bar.foo'); + await click(GENERAL.saveButton); + assert.dom(SES.wif.issuerWarningMessage).exists('issuer modal exists'); + assert + .dom(SES.wif.issuerWarningMessage) + .hasText( + `You are updating the global issuer config. This will overwrite Vault's current issuer and may affect other configurations using this value. Continue?`, + 'modal shows message about overwriting value without the noRead: "if it exists" adage' + ); + await click(SES.wif.issuerWarningCancel); + assert.dom(SES.wif.issuerWarningMessage).doesNotExist('issuer modal is removed on cancel'); + assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.'); + assert.true(this.flashSuccessSpy.notCalled, 'No success flash messages called.'); + assert.true(this.transitionStub.notCalled, 'Does not redirect'); + }); + + test('it shows modal when updating issuer and calls correct APIs on save', async function (assert) { + const newIssuer = `http://bar.${uuidv4()}`; + this.server.post('/identity/oidc/config', (schema, req) => { + const payload = JSON.parse(req.requestBody); + assert.deepEqual(payload, { issuer: newIssuer }, 'payload for issuer is correct'); + return { + id: 'identity-oidc-config', // id needs to match the id on secret-engine-helpers createIssuerConfig + data: null, + warnings: [ + 'If "issuer" is set explicitly, all tokens must be validated against that address, including those issued by secondary clusters. Setting issuer to "" will restore the default behavior of using the cluster\'s api_addr as the issuer.', + ], + }; + }); + this.server.post(configUrl('azure', this.id), () => { + throw new Error( + `Request was made to post the config when it should not have been because no data was changed.` + ); + }); + + await render(hbs` + + `); + await click(SES.wif.accessType('wif')); + assert.dom(GENERAL.inputByAttr('issuer')).hasValue('', 'issuer defaults to empty string'); + await fillIn(GENERAL.inputByAttr('issuer'), newIssuer); + await click(GENERAL.saveButton); + assert.dom(SES.wif.issuerWarningMessage).exists('issuer warning modal exists'); + + await click(SES.wif.issuerWarningSave); + assert.true(this.flashDangerSpy.notCalled, 'No danger flash messages called.'); + assert.true( + this.flashSuccessSpy.calledWith('Issuer saved successfully'), + 'Success flash message called for issuer' + ); + assert.true( + this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id), + 'Transitioned to the configuration index route.' + ); + }); + + test('shows modal when modifying the issuer, has correct payload, and shows flash message on fail', async function (assert) { + assert.expect(7); + this.server.post(configUrl('azure', this.id), () => { + assert.true( + true, + 'post request was made to azure config when unsetting the issuer. test should pass.' + ); + }); + this.server.post('/identity/oidc/config', (_, req) => { + const payload = JSON.parse(req.requestBody); + assert.deepEqual(payload, { issuer: 'http://foo.bar' }, 'correctly sets the issuer'); + return overrideResponse(403); + }); + + await render(hbs` + + `); + await click(SES.wif.accessType('wif')); + assert.dom(GENERAL.inputByAttr('issuer')).hasValue(''); + + await fillIn(GENERAL.inputByAttr('issuer'), 'http://foo.bar'); + await fillIn(GENERAL.inputByAttr('identityTokenAudience'), 'some-value'); + await click(GENERAL.saveButton); + assert.dom(SES.wif.issuerWarningMessage).exists('issuer warning modal exists'); + await click(SES.wif.issuerWarningSave); + + assert.true( + this.flashDangerSpy.calledWith('Issuer was not saved: permission denied'), + 'shows danger flash for issuer save' + ); + assert.true( + this.flashSuccessSpy.calledWith(`Successfully saved ${this.id}'s configuration.`), + "calls the config flash message not the issuer's" + ); + assert.true( + this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', this.id), + 'Transitioned to the configuration index route.' + ); + }); + + test('it does not clear global issuer when toggling accessType', async function (assert) { + await render(hbs` + + `); + await click(SES.wif.accessType('wif')); + assert + .dom(GENERAL.inputByAttr('issuer')) + .hasValue(this.issuerConfig.issuer, 'issuer is what is sent in by the model on first load'); + await fillIn(GENERAL.inputByAttr('issuer'), 'http://ive-changed'); + await click(SES.wif.accessType('azure')); + await click(SES.wif.accessType('wif')); + assert + .dom(GENERAL.inputByAttr('issuer')) + .hasValue( + this.issuerConfig.issuer, + 'issuer value is still the same global value after toggling accessType' + ); + }); + }); + }); + + module('isCommunity', function (hooks) { + hooks.beforeEach(function () { + this.version.type = 'community'; + }); + + for (const type of WIF_ENGINES) { + test(`${type}: it renders fields`, async function (assert) { + this.id = `${type}-${this.uid}`; + this.displayName = allEnginesArray.find((engine) => engine.type === type)?.displayName; + this.issuerConfig = createConfig(this.store, this.id, 'issuer'); + this.mountConfigModel = + type === 'aws' + ? this.store.createRecord('aws/root-config') + : type === 'ssh' + ? this.store.createRecord('ssh/ca-config') + : this.store.createRecord(`${type}/config`); + this.additionalConfigModel = type === 'aws' ? this.store.createRecord('aws/lease-config') : null; + this.mountConfigModel.backend = this.id; + this.additionalConfigModel ? (this.additionalConfigModel.backend = this.id) : null; + this.type = type; + + await render(hbs` + + `); + assert.dom(SES.configureForm).exists(`lands on the ${type} configuration form`); + assert + .dom(SES.wif.accessTypeSection) + .doesNotExist('Access type section does not render for a community user'); + // toggle grouped fields if it exists + const toggleGroup = document.querySelector('[data-test-toggle-group]'); + toggleGroup ? await click(toggleGroup) : null; + // check all the form fields are present + for (const key of expectedConfigKeys(type, true)) { + assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for ${type} account access section.`); + } + assert.dom(GENERAL.inputByAttr('issuer')).doesNotExist(); + }); + } + }); + }); + + module('Edit view', function () { + module('isEnterprise', function (hooks) { + hooks.beforeEach(function () { + this.version.type = 'enterprise'; + }); + for (const type of WIF_ENGINES) { + test(`${type}: it defaults to WIF accessType if WIF fields are already set`, async function (assert) { + this.id = `${type}-${this.uid}`; + this.displayName = allEnginesArray.find((engine) => engine.type === type)?.displayName; + this.mountConfigModel = createConfig(this.store, this.id, `${type}-wif`); + this.type = type; + await render(hbs` + + `); + assert.dom(SES.wif.accessType('wif')).isChecked('WIF accessType is checked'); + assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled'); + assert.dom(SES.wif.accessType(type)).isNotChecked(`${type} accessType is not checked`); + assert.dom(SES.wif.accessType(type)).isDisabled(`${type} accessType is disabled`); + assert + .dom(GENERAL.inputByAttr('identityTokenAudience')) + .hasValue(this.mountConfigModel.identityTokenAudience); + assert + .dom(SES.wif.accessTypeSubtext) + .hasText('You cannot edit Access Type if you have already saved access credentials.'); + assert.dom(GENERAL.ttl.input('Identity token TTL')).hasValue('2'); // 7200 on payload is 2hrs in ttl picker + }); + } + + for (const type of WIF_ENGINES) { + test(`${type}: it renders issuer if global issuer is already set`, async function (assert) { + this.id = `${type}-${this.uid}`; + this.displayName = allEnginesArray.find((engine) => engine.type === type)?.displayName; + this.mountConfigModel = createConfig(this.store, this.id, `${type}-wif`); + this.issuerConfig = createConfig(this.store, this.id, 'issuer'); + this.issuerConfig.issuer = 'https://foo-bar-blah.com'; + this.type = type; + await render(hbs` + + `); + + assert.dom(SES.wif.accessType('wif')).isChecked('WIF accessType is checked'); + assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled'); + assert + .dom(GENERAL.inputByAttr('issuer')) + .hasValue( + this.issuerConfig.issuer, + `it has the global issuer value of ${this.issuerConfig.issuer}` + ); + }); + } + + module('Azure specific', function (hooks) { + hooks.beforeEach(function () { + this.id = `azure-${this.uid}`; + this.mountConfigModel = createConfig(this.store, this.id, 'azure'); + }); + + test('it defaults to Azure accessType if Azure account fields are already set', async function (assert) { + await render(hbs` + + `); + + assert.dom(SES.wif.accessType('azure')).isChecked('Azure accessType is checked'); + assert.dom(SES.wif.accessType('azure')).isDisabled('Azure accessType is disabled'); + assert.dom(SES.wif.accessType('wif')).isNotChecked('WIF accessType is not checked'); + assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled'); + assert + .dom(SES.wif.accessTypeSubtext) + .hasText('You cannot edit Access Type if you have already saved access credentials.'); + }); + + test('it allows you to change accessType if record does not have wif or azure values already set', async function (assert) { + this.mountConfigModel = createConfig(this.store, this.id, 'azure-generic'); + await render(hbs` + + `); + + assert.dom(SES.wif.accessType('wif')).isNotDisabled('WIF accessType is NOT disabled'); + assert.dom(SES.wif.accessType('azure')).isNotDisabled('Azure accessType is NOT disabled'); + }); + + test('it shows previously saved config information', async function (assert) { + this.id = `azure-${this.uid}`; + this.mountConfigModel = createConfig(this.store, this.id, 'azure-generic'); + await render(hbs` + + `); + assert.dom(GENERAL.inputByAttr('subscriptionId')).hasValue(this.mountConfigModel.subscriptionId); + assert.dom(GENERAL.inputByAttr('clientId')).hasValue(this.mountConfigModel.clientId); + assert.dom(GENERAL.inputByAttr('tenantId')).hasValue(this.mountConfigModel.tenantId); + assert + .dom(GENERAL.inputByAttr('clientSecret')) + .hasValue('**********', 'clientSecret is masked on edit the value'); + }); + + test('it requires a double click to change the client secret', async function (assert) { + this.id = `azure-${this.uid}`; + await render(hbs` + + `); + + this.server.post(configUrl('azure', this.id), (schema, req) => { + const payload = JSON.parse(req.requestBody); + assert.strictEqual( + payload.client_secret, + 'new-secret', + 'post request was made to azure/config with the updated client_secret.' + ); + }); + + await click(GENERAL.enableField('clientSecret')); + await click('[data-test-button="toggle-masked"]'); + await fillIn(GENERAL.inputByAttr('clientSecret'), 'new-secret'); + await click(GENERAL.saveButton); + }); + }); + + module('AWS specific', function (hooks) { + hooks.beforeEach(function () { + this.id = `aws-${this.uid}`; + this.mountConfigModel = createConfig(this.store, this.id, 'aws'); + }); + test('it defaults to IAM accessType if IAM fields are already set', async function (assert) { + await render(hbs` + + `); + assert.dom(SES.wif.accessType('aws')).isChecked('IAM accessType is checked'); + assert.dom(SES.wif.accessType('aws')).isDisabled('IAM accessType is disabled'); + assert.dom(SES.wif.accessType('wif')).isNotChecked('WIF accessType is not checked'); + assert.dom(SES.wif.accessType('wif')).isDisabled('WIF accessType is disabled'); + assert + .dom(SES.wif.accessTypeSubtext) + .hasText('You cannot edit Access Type if you have already saved access credentials.'); + }); + + test('it allows you to change access type if record does not have wif or iam values already set', async function (assert) { + this.mountConfigModel = createConfig(this.store, this.id, 'aws-no-access'); + await render(hbs` + + `); + assert.dom(SES.wif.accessType('wif')).isNotDisabled('WIF accessType is NOT disabled'); + assert.dom(SES.wif.accessType('aws')).isNotDisabled('IAM accessType is NOT disabled'); + }); + + test('it shows previously saved root and lease information', async function (assert) { + this.additionalConfigModel = createConfig(this.store, this.id, 'aws-lease'); + await render(hbs` + + `); + + assert.dom(GENERAL.inputByAttr('accessKey')).hasValue(this.mountConfigModel.accessKey); + assert + .dom(GENERAL.inputByAttr('secretKey')) + .hasValue('**********', 'secretKey is masked on edit the value'); + + await click(GENERAL.toggleGroup('Root config options')); + assert.dom(GENERAL.inputByAttr('region')).hasValue(this.mountConfigModel.region); + assert.dom(GENERAL.inputByAttr('iamEndpoint')).hasValue(this.mountConfigModel.iamEndpoint); + assert.dom(GENERAL.inputByAttr('stsEndpoint')).hasValue(this.mountConfigModel.stsEndpoint); + assert.dom(GENERAL.inputByAttr('maxRetries')).hasValue('1'); + // Check lease config values + assert.dom(GENERAL.ttl.input('Default Lease TTL')).hasValue('50'); + assert.dom(GENERAL.ttl.input('Max Lease TTL')).hasValue('55'); + }); + + test('it requires a double click to change the secret key', async function (assert) { + await render(hbs` + + `); + + this.server.post(configUrl('aws', this.id), (schema, req) => { + const payload = JSON.parse(req.requestBody); + assert.strictEqual( + payload.secret_key, + 'new-secret', + 'post request was made to config/root with the updated secret_key.' + ); + }); + + await click(GENERAL.enableField('secretKey')); + await click('[data-test-button="toggle-masked"]'); + await fillIn(GENERAL.inputByAttr('secretKey'), 'new-secret'); + await click(GENERAL.saveButton); + }); + }); + }); + + module('isCommunity', function (hooks) { + hooks.beforeEach(function () { + this.version.type = 'community'; + }); + for (const type of WIF_ENGINES) { + test(`${type}:it does not show access type but defaults to type "account" fields`, async function (assert) { + this.id = `${type}-${this.uid}`; + this.mountConfigModel = createConfig(this.store, this.id, `${type}-generic`); + this.displayName = allEnginesArray.find((engine) => engine.type === type)?.displayName; + this.type = type; + await render(hbs` + + `); + assert.dom(SES.wif.accessTypeSection).doesNotExist('Access type section does not render'); + // toggle grouped fields if it exists + const toggleGroup = document.querySelector('[data-test-toggle-group]'); + toggleGroup ? await click(toggleGroup) : null; + + for (const key of expectedConfigKeys(type, true)) { + if (key === 'secretKey' || key === 'clientSecret') return; // these keys are not returned by the API + assert + .dom(GENERAL.inputByAttr(key)) + .hasValue( + this.mountConfigModel[key], + `${key} for ${type}: has the expected value set on the config` + ); + } + }); + } + }); + }); +}); diff --git a/ui/types/vault/models/aws/lease-config.d.ts b/ui/types/vault/models/aws/lease-config.d.ts deleted file mode 100644 index f08995c1c9..0000000000 --- a/ui/types/vault/models/aws/lease-config.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import Model from '@ember-data/model'; -import type { ModelValidations } from 'vault/vault/app-types'; - -export default class AwsLeaseConfig extends Model { - backend: any; - leaseMax: any; - lease: any; - get attrs(): string[]; - // for some reason the following Model attrs don't exist on the Model definition - changedAttributes(): { - [key: string]: unknown[]; - }; - isNew: boolean; - save(): void; - unloadRecord(): void; - validate(): ModelValidations; -} diff --git a/ui/types/vault/models/aws/root-config.d.ts b/ui/types/vault/models/aws/root-config.d.ts deleted file mode 100644 index 87a300450b..0000000000 --- a/ui/types/vault/models/aws/root-config.d.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import type Model from '@ember-data/model'; - -export default class AwsRootConfig extends Model { - backend: any; - accessKey: any; - secretKey: any; - roleArn: any; - identityTokenAudience: any; - identityTokenTtl: any; - region: any; - iamEndpoint: any; - stsEndpoint: any; - maxRetries: any; - get attrs(): any; - get fieldGroupsWif(): any; - get fieldGroupsIam(): any; - formFieldGroups(accessType?: string): { - [key: string]: string[]; - }[]; - // for some reason the following Model attrs don't exist on the Model definition - changedAttributes(): { - [key: string]: unknown[]; - }; - isNew: boolean; - save(): void; - unloadRecord(): void; -} diff --git a/ui/types/vault/models/azure/config.d.ts b/ui/types/vault/models/azure/config.d.ts deleted file mode 100644 index 70decffc34..0000000000 --- a/ui/types/vault/models/azure/config.d.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -import type Model from '@ember-data/model'; -import type { ModelValidations } from 'vault/app-types'; - -export default class AzureConfig extends Model { - backend: string; - subscriptionId: string | undefined; - tenantId: string | undefined; - clientId: string | undefined; - clientSecret: string | undefined; - identityTokenAudience: string | undefined; - identityTokenTtl: any; - environment: string | undefined; - rootPasswordTtl: string | undefined; - - get displayAttrs(): any; - get isWifPluginConfigured(): boolean; - get isAzureAccountConfigured(): boolean; - get fieldGroupsWif(): any; - get fieldGroupsAzure(): any; - formFieldGroups(accessType?: string): { - [key: string]: string[]; - }[]; - changedAttributes(): { - [key: string]: unknown[]; - }; - isNew: boolean; - save(): void; - unloadRecord(): void; -} diff --git a/ui/types/vault/models/secret-engine/additional-config.d.ts b/ui/types/vault/models/secret-engine/additional-config.d.ts new file mode 100644 index 0000000000..18f8088746 --- /dev/null +++ b/ui/types/vault/models/secret-engine/additional-config.d.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import type Model from '@ember-data/model'; +import type { ModelValidations, FormField } from 'vault/app-types'; + +export default class SecretEngineAdditionalConfigModel extends Model { + backend: string; + type: string; + // aws lease + leaseMax: any; + lease: any; + + get displayAttrs(): any; + + formFields: FormField[]; + changedAttributes(): { + [key: string]: unknown[]; + }; + isNew: boolean; + save(): void; + unloadRecord(): void; + destroyRecord(): void; + rollbackAttributes(): void; + validate(): ModelValidations; +} diff --git a/ui/types/vault/models/secret-engine/mount-config.d.ts b/ui/types/vault/models/secret-engine/mount-config.d.ts new file mode 100644 index 0000000000..636ce52d68 --- /dev/null +++ b/ui/types/vault/models/secret-engine/mount-config.d.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import type Model from '@ember-data/model'; +import type { ModelValidations, FormFieldGroups } from 'vault/app-types'; + +export default class SecretEngineMountConfigModel extends Model { + backend: string; + type: string; + // aws + accessKey: any; + secretKey: any; + roleArn: any; + region: any; + iamEndpoint: any; + stsEndpoint: any; + maxRetries: any; + // azure + subscriptionId: string | undefined; + tenantId: string | undefined; + clientId: string | undefined; + clientSecret: string | undefined; + environment: string | undefined; + rootPasswordTtl: string | undefined; + // gcp + credentials: string | undefined; + ttl: any; + maxTtl: any; + secretAccountEmail: string | undefined; + // wif + identityTokenAudience: string | undefined; + identityTokenTtl: any; + + get displayAttrs(): any; + get isConfigured(): boolean; // used only for secret engines that return a 200 when configuration has not be set. + get isWifPluginConfigured(): boolean; + get isAccountPluginConfigured(): boolean; + get fieldGroupsWif(): any; + get fieldGroupsAccount(): any; + + formFieldGroups: FormFieldGroups[]; + + changedAttributes(): { + [key: string]: unknown[]; + }; + isNew: boolean; + save(): void; + unloadRecord(): void; + destroyRecord(): void; + rollbackAttributes(): void; +}