From 14082d08f1e0b16976bcc7699007baa354d2c7cc Mon Sep 17 00:00:00 2001 From: Angel Garbarino Date: Thu, 30 Jan 2025 13:37:20 -0700 Subject: [PATCH] Add GCP secret engine configuration Create/Edit views (#29423) * gcp initial changes * acceptance test coverage for gcp * update config-wif component test so tests are passing * specific gcp test coverage * changelog * comment clean up * one more test * comment things * address pr comments --- changelog/29423.txt | 6 + ui/app/adapters/gcp/config.js | 20 ++ .../secret-engine/configuration-details.hbs | 17 +- ui/app/helpers/mountable-secret-engines.js | 2 +- ui/app/models/gcp/config.js | 79 +++-- .../secrets/backend/configuration/edit.ts | 1 + .../secrets/backend/configuration/index.hbs | 25 +- .../backend/aws/aws-configuration-test.js | 4 +- .../backend/azure/azure-configuration-test.js | 4 +- .../backend/gcp/gcp-configuration-test.js | 299 +++++++++++++----- ui/tests/helpers/general-selectors.ts | 2 + .../secret-engine/secret-engine-helpers.js | 31 +- .../configuration-details-test.js | 2 +- .../secret-engine/configure-wif-test.js | 120 ++++++- 14 files changed, 469 insertions(+), 143 deletions(-) create mode 100644 changelog/29423.txt diff --git a/changelog/29423.txt b/changelog/29423.txt new file mode 100644 index 0000000000..bf3c663dc2 --- /dev/null +++ b/changelog/29423.txt @@ -0,0 +1,6 @@ +```release-note:improvement +ui: Adds ability to edit, create, and view the GCP secrets engine configuration. +``` +```release-note:improvement +ui (enterprise): Allow WIF configuration on the GCP secrets engine. +``` diff --git a/ui/app/adapters/gcp/config.js b/ui/app/adapters/gcp/config.js index fd2129be13..c680969001 100644 --- a/ui/app/adapters/gcp/config.js +++ b/ui/app/adapters/gcp/config.js @@ -23,4 +23,24 @@ export default class GcpConfig extends ApplicationAdapter { }; }); } + + createOrUpdate(store, type, snapshot) { + const serializer = store.serializerFor(type.modelName); + const data = serializer.serialize(snapshot); + const backend = snapshot.record.backend; + return this.ajax(this._url(backend), 'POST', { data }).then((resp) => { + return { + ...resp, + id: backend, + }; + }); + } + + createRecord() { + return this.createOrUpdate(...arguments); + } + + updateRecord() { + return this.createOrUpdate(...arguments); + } } diff --git a/ui/app/components/secret-engine/configuration-details.hbs b/ui/app/components/secret-engine/configuration-details.hbs index 46410ac748..5a682b2a56 100644 --- a/ui/app/components/secret-engine/configuration-details.hbs +++ b/ui/app/components/secret-engine/configuration-details.hbs @@ -30,15 +30,12 @@ @title="{{@typeDisplay}} not configured" @message="Get started by configuring your {{@typeDisplay}} secrets engine." > - {{! TODO: short-term conditional to be removed once configuration for gcp is merged. }} - {{#unless (eq @typeDisplay "Google Cloud")}} - - {{/unless}} + {{/each}} \ No newline at end of file diff --git a/ui/app/helpers/mountable-secret-engines.js b/ui/app/helpers/mountable-secret-engines.js index 6f9b9d4a5c..c65221fe0d 100644 --- a/ui/app/helpers/mountable-secret-engines.js +++ b/ui/app/helpers/mountable-secret-engines.js @@ -135,7 +135,7 @@ const MOUNTABLE_SECRET_ENGINES = [ ]; // A list of Workload Identity Federation engines. -export const WIF_ENGINES = ['aws', 'azure']; +export const WIF_ENGINES = ['aws', 'azure', 'gcp']; export function wifEngines() { return WIF_ENGINES.slice(); diff --git a/ui/app/models/gcp/config.js b/ui/app/models/gcp/config.js index 93d7504dc8..cc937eeb4c 100644 --- a/ui/app/models/gcp/config.js +++ b/ui/app/models/gcp/config.js @@ -4,28 +4,12 @@ */ import Model, { attr } from '@ember-data/model'; -import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; +import fieldToAttrs, { expandAttributeMeta } from 'vault/utils/field-to-attrs'; export default class GcpConfig extends Model { @attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord - /* GCP config fields */ - @attr({ - label: 'Config TTL', - editType: 'ttl', - helperTextDisabled: 'The TTL (time-to-live) of generated tokens.', - }) - ttl; - - @attr({ - label: 'Max TTL', - editType: 'ttl', - helperTextDisabled: - 'Specifies the maximum config TTL (time-to-live) for long-lived credentials (i.e. service account keys).', - }) - maxTtl; - - /* GCP credential config field */ + // GCP only field @attr('string', { label: 'JSON credentials', subText: @@ -35,7 +19,7 @@ export default class GcpConfig extends Model { }) credentials; // obfuscated, never returned by API. - /* WIF config fields */ + // WIF only fields @attr('string', { subText: 'The audience claim value for plugin identity tokens. Must match an allowed audience configured for the targetĀ IAM OIDC identity provider.', @@ -45,7 +29,7 @@ export default class GcpConfig extends Model { @attr({ label: 'Identity token TTL', helperTextDisabled: - 'The TTL of generated tokens. Defaults to 1 hour, toggle on to specify a different value.', + 'The TTL of generated tokens. Defaults to 1 hour, turn on the toggle to specify a different value.', helperTextEnabled: 'The TTL of generated tokens.', editType: 'ttl', }) @@ -56,6 +40,26 @@ export default class GcpConfig extends Model { }) serviceAccountEmail; + // Fields that show regardless of access type + @attr({ + label: 'Config TTL', + editType: 'ttl', + helperTextDisabled: 'Vault will use the default config TTL (time-to-live) for long-lived credentials.', + helperTextEnabled: + 'The default config TTL (time-to-live) for long-lived credentials (i.e. service account keys).', + }) + ttl; + + @attr({ + label: 'Max TTL', + editType: 'ttl', + helperTextDisabled: + 'Vault will use the default maximum config TTL (time-to-live) for long-lived credentials.', + helperTextEnabled: + 'The maximum config TTL (time-to-live) for long-lived credentials (i.e. service account keys).', + }) + maxTtl; + configurableParams = [ 'credentials', 'serviceAccountEmail', @@ -65,8 +69,43 @@ export default class GcpConfig extends Model { 'identityTokenTtl', ]; + get isWifPluginConfigured() { + return !!this.identityTokenAudience || !!this.identityTokenTtl || !!this.serviceAccountEmail; + } + + isAccountPluginConfigured = false; + // the "credentials" param is not checked for "isAccountPluginConfigured" because it's never return by the API + // additionally credentials can be set via GOOGLE_APPLICATION_CREDENTIALS env var so we cannot call it a required field in the ui. + // thus we can never say for sure if the account accessType has been configured so we always return false + get displayAttrs() { const formFields = expandAttributeMeta(this, this.configurableParams); return formFields.filter((attr) => attr.name !== 'credentials'); } + + get fieldGroupsWif() { + return fieldToAttrs(this, this.formFieldGroups('wif')); + } + + get fieldGroupsAccount() { + return fieldToAttrs(this, this.formFieldGroups('account')); + } + + formFieldGroups(accessType = 'account') { + const formFieldGroups = []; + if (accessType === 'wif') { + formFieldGroups.push({ + default: ['identityTokenAudience', 'serviceAccountEmail', 'identityTokenTtl'], + }); + } + if (accessType === 'account') { + formFieldGroups.push({ + default: ['credentials'], + }); + } + formFieldGroups.push({ + 'More options': ['ttl', 'maxTtl'], + }); + return formFieldGroups; + } } 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 fd94c4c548..07df70c527 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/configuration/edit.ts +++ b/ui/app/routes/vault/cluster/secrets/backend/configuration/edit.ts @@ -22,6 +22,7 @@ import type VersionService from 'vault/services/version'; const MOUNT_CONFIG_MODEL_NAMES: Record = { aws: ['aws/root-config', 'aws/lease-config'], azure: ['azure/config'], + gcp: ['gcp/config'], ssh: ['ssh/ca-config'], }; diff --git a/ui/app/templates/vault/cluster/secrets/backend/configuration/index.hbs b/ui/app/templates/vault/cluster/secrets/backend/configuration/index.hbs index 3b3c5c4bf9..abaef090a3 100644 --- a/ui/app/templates/vault/cluster/secrets/backend/configuration/index.hbs +++ b/ui/app/templates/vault/cluster/secrets/backend/configuration/index.hbs @@ -6,20 +6,17 @@ {{#if this.isConfigurable}} - {{! TODO: short-term conditional to be removed once configuration for gcp is merged. }} - {{#unless (eq this.typeDisplay "Google Cloud")}} - - - - Configure - - - - {{/unless}} + + + + Configure + + + { + assert.true(true, 'request made to config when navigating to the configuration page.'); + return { data: { id: this.path, type: this.type, ...gcpAccountAttrs } }; + }); + await enablePage.enable(this.type, this.path); + for (const key of expectedConfigKeys(this.type)) { + if (key === 'Credentials') continue; // not returned by the API + const responseKeyAndValue = expectedValueOfConfigKeys(this.type, key); + assert + .dom(GENERAL.infoRowValue(key)) + .hasText(responseKeyAndValue, `value for ${key} on the ${this.type} config details exists.`); + } + // check mount configuration details are present and accurate. + await click(SES.configurationToggle); + assert + .dom(GENERAL.infoRowValue('Path')) + .hasText(`${this.path}/`, 'mount path is displayed in the configuration details'); + // cleanup + await runCmd(`delete sys/mounts/${this.path}`); + }); + }); + + module('create', function () { + test('it should save gcp account accessType options', async function (assert) { + await enablePage.enable(this.type, this.path); + await click(SES.configTab); + await click(SES.configure); + await fillInGcpConfig(); + await click(GENERAL.saveButton); + assert.true( + this.flashSuccessSpy.calledWith(`Successfully saved ${this.path}'s configuration.`), + 'Success flash message is rendered showing the GCP model configuration was saved.' + ); + + assert + .dom(GENERAL.infoRowValue('Config TTL')) + .hasText('2 hours', 'Config TTL, a generic account specific field, has been set.'); + assert + .dom(GENERAL.infoRowValue('Max TTL')) + .hasText('2 hours 16 minutes 40 seconds', 'Max TTL, a generic field, has been set.'); + assert + .dom(GENERAL.infoRowValue('Credentials')) + .doesNotExist('credentials are not shown in the configuration details'); + // cleanup + await runCmd(`delete sys/mounts/${this.path}`); + }); + }); + + module('edit', function (hooks) { + hooks.beforeEach(async function () { + const genericAttrs = { + configTtl: '2h', + maxTtl: '4h', + }; + this.server.get(`${this.path}/config`, () => { + return { data: { id: this.path, type: this.type, ...genericAttrs } }; + }); + await enablePage.enable(this.type, this.path); + }); + + test('it should save credentials', async function (assert) { + assert.expect(3); + const credentials = '{"some-key":"some-value"}'; + await click(SES.configure); + + this.server.post(configUrl('gcp', this.path), (schema, req) => { + const payload = JSON.parse(req.requestBody); + assert.strictEqual(credentials, payload.credentials, 'credentials are sent in post request'); + assert.strictEqual( + undefined, + payload.configTtl, + 'config_ttl is not included in payload if value has not been updated' + ); + assert.strictEqual( + undefined, + payload.maxTtl, + 'max_ttl is not included in payload if value has not been updated' + ); + }); + + await click(GENERAL.textToggle); + await fillIn(GENERAL.textToggleTextarea, credentials); + await click(GENERAL.saveButton); + // cleanup + await runCmd(`delete sys/mounts/${this.path}`); + }); + + test('it should not save credentials if it has NOT been changed', async function (assert) { + assert.expect(3); + await click(SES.configure); + + this.server.post(configUrl('gcp', this.path), (schema, req) => { + const payload = JSON.parse(req.requestBody); + assert.strictEqual(payload.credentials, undefined, 'credentials are not sent in post request'); + + assert.strictEqual( + payload.ttl, + '10800s', + 'config_ttl is included in payload because the value was updated' + ); + assert.strictEqual( + payload.maxTtl, + undefined, + 'max_ttl is not included in payload if value has not been updated' + ); + }); + + await click(GENERAL.toggleGroup('More options')); + await click(GENERAL.ttl.toggle('Config TTL')); + await fillIn(GENERAL.ttl.input('Config TTL'), '10800'); + await click(GENERAL.saveButton); + // cleanup + await runCmd(`delete sys/mounts/${this.path}`); + }); + }); + + module('Error handling', function () { + test('it prevents transition and shows api error if config errored on save', async function (assert) { + await enablePage.enable(this.type, this.path); + + this.server.post(configUrl(this.type, this.path), () => { + return overrideResponse(400, { errors: ['my goodness, that did not work!'] }); + }); + + await click(SES.configTab); + await click(SES.configure); + await fillInGcpConfig(); + await click(GENERAL.saveButton); + + assert + .dom(GENERAL.messageError) + .hasText('Error my goodness, that did not work!', 'API error shows on form'); + assert.strictEqual( + currentURL(), + `/vault/secrets/${this.path}/configuration/edit`, + 'the form did not transition because the save failed.' + ); + // cleanup + await runCmd(`delete sys/mounts/${this.path}`); + }); + + test('it should show API error when configuration read fails', async function (assert) { + this.server.get(configUrl(this.type, this.path), () => { + return overrideResponse(400, { errors: ['bad request'] }); + }); + await enablePage.enable(this.type, this.path); + assert.dom(SES.error.title).hasText('Error', 'shows the secrets backend error route'); + }); + }); + }); + + module('Enterprise', function (hooks) { hooks.beforeEach(function () { this.version.type = 'enterprise'; - }); - - test('it should show empty state and navigate to configuration view after mounting the GCP engine', async function (assert) { - const path = `GCP-${this.uid}`; - await visit('/vault/settings/mount-secret-backend'); - await mountBackend(this.type, path); - - assert.strictEqual( - currentURL(), - `/vault/secrets/${path}/configuration`, - 'navigated to configuration view' - ); - assert.dom(GENERAL.emptyStateTitle).hasText('Google Cloud not configured'); - assert.dom(GENERAL.emptyStateActions).doesNotContainText('Configure GCP'); - // cleanup - await runCmd(`delete sys/mounts/${path}`); - }); - - test('it should not show "Configure" from toolbar', async function (assert) { - const path = `GCP-${this.uid}`; - await enablePage.enable(this.type, path); - assert.dom(SES.configure).doesNotExist('Configure button does not exist.'); - // cleanup - await runCmd(`delete sys/mounts/${path}`); - }); - - test('it should show configuration details with WIF options configured', async function (assert) { - const path = `GCP-${this.uid}`; - const wifAttrs = { + this.wifAttrs = { service_account_email: 'service-email', identity_token_audience: 'audience', identity_token_ttl: 720000, max_ttl: 14400, ttl: 3600, }; - this.server.get(`${path}/config`, () => { + }); + + test('it should show configuration details with WIF options configured', async function (assert) { + this.server.get(`${this.path}/config`, () => { assert.true(true, 'request made to config when navigating to the configuration page.'); - return { data: { id: path, type: this.type, ...wifAttrs } }; + return { data: { id: this.path, type: this.type, ...this.wifAttrs } }; }); - await enablePage.enable(this.type, path); + await enablePage.enable(this.type, this.path); for (const key of expectedConfigKeys('gcp-wif')) { const responseKeyAndValue = expectedValueOfConfigKeys(this.type, key); assert @@ -86,48 +259,32 @@ module('Acceptance | GCP | configuration', function (hooks) { await click(SES.configurationToggle); assert .dom(GENERAL.infoRowValue('Path')) - .hasText(`${path}/`, 'mount path is displayed in the configuration details'); + .hasText(`${this.path}/`, 'mount path is displayed in the configuration details'); // cleanup - await runCmd(`delete sys/mounts/${path}`); + await runCmd(`delete sys/mounts/${this.path}`); }); - test('it should show configuration details with GCP account options configured', async function (assert) { - const path = `GCP-${this.uid}`; - const GCPAccountAttrs = { - credentials: '{"some-key":"some-value"}', - ttl: '1 hour', - max_ttl: '4 hours', - }; - this.server.get(`${path}/config`, () => { - 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); - for (const key of expectedConfigKeys(this.type)) { - if (key === 'Credentials') continue; // not returned by the API - const responseKeyAndValue = expectedValueOfConfigKeys(this.type, key); + module('Error handling', function () { + test('it shows API error if user previously set credentials but tries to edit the configuration with wif fields', async function (assert) { + await enablePage.enable(this.type, this.path); + await click(SES.configTab); + await click(SES.configure); + await fillInGcpConfig(); + await click(GENERAL.saveButton); // save GCP credentials + + await click(SES.configure); // navigate so you can edit that configuration + await fillInGcpConfig(true); + await click(GENERAL.saveButton); // try and save wif fields assert - .dom(GENERAL.infoRowValue(key)) - .hasText(responseKeyAndValue, `value for ${key} on the ${this.type} config details exists.`); - } - // check mount configuration details are present and accurate. - await click(SES.configurationToggle); - assert - .dom(GENERAL.infoRowValue('Path')) - .hasText(`${path}/`, 'mount path is displayed in the configuration details'); - // 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 = `GCP-${this.uid}`; - // interrupt get and return API error - this.server.get(configUrl(this.type, path), () => { - return overrideResponse(400, { errors: ['bad request'] }); + .dom(GENERAL.messageError) + .hasText( + `Error only one of 'credentials' or 'identity_token_audience' can be set`, + 'api error about conflicting fields is shown' + ); + assert.dom(GENERAL.inlineError).hasText('There was an error submitting this form.'); + // cleanup + await runCmd(`delete sys/mounts/${this.path}`); }); - await enablePage.enable(this.type, path); - assert.dom(SES.error.title).hasText('Error', 'shows the secrets backend error route'); }); }); }); diff --git a/ui/tests/helpers/general-selectors.ts b/ui/tests/helpers/general-selectors.ts index c9159350d3..c3bc705b56 100644 --- a/ui/tests/helpers/general-selectors.ts +++ b/ui/tests/helpers/general-selectors.ts @@ -43,6 +43,8 @@ export const GENERAL = { infoRowValue: (label: string) => `[data-test-value-div="${label}"]`, inputByAttr: (attr: string) => `[data-test-input="${attr}"]`, selectByAttr: (attr: string) => `[data-test-select="${attr}"]`, + textToggle: '[data-test-text-toggle]', + textToggleTextarea: '[data-test-text-file-textarea]', toggleInput: (attr: string) => `[data-test-toggle-input="${attr}"]`, toggleGroup: (attr: string) => `[data-test-toggle-group="${attr}"]`, ttl: { diff --git a/ui/tests/helpers/secret-engine/secret-engine-helpers.js b/ui/tests/helpers/secret-engine/secret-engine-helpers.js index 0919d10550..1c702d25d2 100644 --- a/ui/tests/helpers/secret-engine/secret-engine-helpers.js +++ b/ui/tests/helpers/secret-engine/secret-engine-helpers.js @@ -185,8 +185,8 @@ const createGcpConfig = (store, backend, accessType = 'gcp') => { data: { backend, credentials: '{"some-key":"some-value"}', - ttl: '1 hour', - max_ttl: '4 hours', + ttl: '100s', + max_ttl: '101s', }, }); } @@ -215,7 +215,10 @@ export const createConfig = (store, backend, type) => { case 'azure-generic': return createAzureConfig(store, backend, 'generic'); case 'gcp': + case 'gcp-generic': return createGcpConfig(store, backend); + case 'gcp-wif': + return createGcpConfig(store, backend, 'wif'); } }; /* Manually create the configuration by filling in the configuration form */ @@ -266,6 +269,24 @@ export const fillInAzureConfig = async (situation = 'azure') => { } }; +export const fillInGcpConfig = async (withWif = false) => { + if (withWif) { + await click(SES.wif.accessType('wif')); // toggle to wif + await fillIn(GENERAL.inputByAttr('identityTokenAudience'), 'azure-audience'); + await click(GENERAL.ttl.toggle('Identity token TTL')); + await fillIn(GENERAL.ttl.input('Identity token TTL'), '7200'); + await fillIn(GENERAL.inputByAttr('serviceAccountEmail'), 'some@email.com'); + } else { + await click(GENERAL.toggleGroup('More options')); + await click(GENERAL.ttl.toggle('Config TTL')); + await fillIn(GENERAL.ttl.input('Config TTL'), '7200'); + await click(GENERAL.ttl.toggle('Max TTL')); + await fillIn(GENERAL.ttl.input('Max TTL'), '8200'); + await click(GENERAL.textToggle); + await fillIn(GENERAL.textToggleTextarea, '{"some-key":"some-value"}'); + } +}; + /* Generate arrays of keys to iterate over. * used to check details of the secret engine configuration * and used to check the form to configure the secret engine @@ -283,7 +304,7 @@ const azureWifKeys = [...genericAzureKeys, ...genericWifKeys]; // GCP specific keys const genericGcpKeys = ['Config TTL', 'Max TTL']; const gcpKeys = [...genericGcpKeys, 'Credentials']; -const gcpWifKeys = [...genericGcpKeys, ...genericWifKeys, 'Service account email']; +const gcpWifKeys = [...genericWifKeys, 'Service account email']; // SSH specific keys const sshKeys = ['Private key', 'Public key', 'Generate signing key']; @@ -349,9 +370,9 @@ const valueOfGcpKeys = (string) => { case 'Service account email': return 'service-email'; case 'Config TTL': - return '1 hour'; + return '1 minute 40 seconds'; case 'Max TTL': - return '4 hours'; + return '1 minute 41 seconds'; case 'Identity token audience': return 'audience'; case 'Identity token TTL': diff --git a/ui/tests/integration/components/secret-engine/configuration-details-test.js b/ui/tests/integration/components/secret-engine/configuration-details-test.js index cde23e23a0..f4be048e7e 100644 --- a/ui/tests/integration/components/secret-engine/configuration-details-test.js +++ b/ui/tests/integration/components/secret-engine/configuration-details-test.js @@ -18,7 +18,7 @@ import { const allEnginesArray = allEngines(); // saving as const so we don't invoke the method multiple times via the for loop -module('Integration | Component | SecretEngine/ConfigurationDetails', function (hooks) { +module('Integration | Component | SecretEngine::ConfigurationDetails', function (hooks) { setupRenderingTest(hooks); hooks.beforeEach(function () { diff --git a/ui/tests/integration/components/secret-engine/configure-wif-test.js b/ui/tests/integration/components/secret-engine/configure-wif-test.js index 6126fb3e66..04c00bf3c1 100644 --- a/ui/tests/integration/components/secret-engine/configure-wif-test.js +++ b/ui/tests/integration/components/secret-engine/configure-wif-test.js @@ -44,7 +44,7 @@ module('Integration | Component | SecretEngine::ConfigureWif', function (hooks) }); module('Create view', function () { - module('isEnterprise', function (hooks) { + module('Enterprise', function (hooks) { hooks.beforeEach(function () { this.version.type = 'enterprise'; }); @@ -74,11 +74,20 @@ module('Integration | Component | SecretEngine::ConfigureWif', function (hooks) 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` - ); + if (key === 'configTtl' || key === 'maxTtl') { + // because toggle.hbs passes in the name rather than the camelized attr, we have a difference of data-test=attrName vs data-test="Item name" being passed into the data-test selectors. Long-term solution we should match toggle.hbs selectors to formField.hbs selectors syntax + assert + .dom(GENERAL.ttl.toggle(key === 'configTtl' ? 'Config TTL' : 'Max TTL')) + .exists( + `${key} shows for ${type} configuration create section when wif is not the access type.` + ); + } else { + 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')) @@ -604,7 +613,7 @@ module('Integration | Component | SecretEngine::ConfigureWif', function (hooks) }); }); - module('isCommunity', function (hooks) { + module('Community', function (hooks) { hooks.beforeEach(function () { this.version.type = 'community'; }); @@ -637,7 +646,13 @@ module('Integration | Component | SecretEngine::ConfigureWif', function (hooks) 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.`); + if (key === 'configTtl' || key === 'maxTtl') { + assert + .dom(GENERAL.ttl.toggle(key === 'configTtl' ? 'Config TTL' : 'Max TTL')) + .exists(`${key} shows for ${type} account access section.`); + } else { + assert.dom(GENERAL.inputByAttr(key)).exists(`${key} shows for ${type} account access section.`); + } } assert.dom(GENERAL.inputByAttr('issuer')).doesNotExist(); }); @@ -646,7 +661,7 @@ module('Integration | Component | SecretEngine::ConfigureWif', function (hooks) }); module('Edit view', function () { - module('isEnterprise', function (hooks) { + module('Enterprise', function (hooks) { hooks.beforeEach(function () { this.version.type = 'enterprise'; }); @@ -830,9 +845,66 @@ module('Integration | Component | SecretEngine::ConfigureWif', function (hooks) await click(GENERAL.saveButton); }); }); + + module('GCP specific', function (hooks) { + // GCP is unique in that "credentials" is the only mutually exclusive GCP account attr and it's never returned from the API. Thus, we can only check for the presence of configured wif fields to determine if the accessType should be preselected to wif and disabled. + // If the user has configured the credentials field, the ui will not know until the user tries to save WIF fields. This is a limitation of the API and surfaced to the user in a descriptive API error. + // We cover some of this workflow here and error testing in the gcp-configuration acceptance test. + hooks.beforeEach(function () { + this.id = `gcp-${this.uid}`; + this.mountConfigModel = createConfig(this.store, this.id, 'gcp'); + this.type = 'gcp'; + this.displayName = 'Google Cloud'; + }); + test('it allows you to change access type if no wif fields are set', async function (assert) { + await render(hbs` + + `); + + assert.dom(SES.wif.accessType('gcp')).isChecked('GCP accessType is checked'); + assert + .dom(SES.wif.accessType('gcp')) + .isNotDisabled( + 'GCP accessType is not disabled because we cannot determine if credentials was set as it is not returned by the api.' + ); + assert.dom(SES.wif.accessType('wif')).isNotChecked('WIF accessType is not checked'); + assert.dom(SES.wif.accessType('wif')).isNotDisabled('WIF accessType is not disabled'); + assert + .dom(SES.wif.accessTypeSubtext) + .hasText( + 'Choose the way to configure access to Google Cloud. Access can be configured either using Google Cloud account credentials or with the Plugin Workload Identity Federation (WIF).' + ); + }); + + test('it sets access type to wif if wif fields are set', async function (assert) { + this.mountConfigModel = createConfig(this.store, this.id, 'gcp-wif'); + await render(hbs` + + `); + + assert.dom(SES.wif.accessType('wif')).isChecked('WIF accessType is checked'); + assert + .dom(SES.wif.accessType('gcp')) + .isDisabled('GCP accessType IS disabled because wif attributes are set.'); + + assert + .dom(SES.wif.accessTypeSubtext) + .hasText('You cannot edit Access Type if you have already saved access credentials.'); + }); + + test('it shows previously saved config information', async function (assert) { + this.mountConfigModel = createConfig(this.store, this.id, 'gcp-generic'); + await render(hbs` + + `); + await click(GENERAL.toggleGroup('More options')); + assert.dom(GENERAL.ttl.input('Config TTL')).hasValue('100'); + assert.dom(GENERAL.ttl.input('Max TTL')).hasValue('101'); + }); + }); }); - module('isCommunity', function (hooks) { + module('Community', function (hooks) { hooks.beforeEach(function () { this.version.type = 'community'; }); @@ -851,13 +923,27 @@ module('Integration | Component | SecretEngine::ConfigureWif', function (hooks) 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` - ); + if (key === 'secretKey' || key === 'clientSecret' || key === 'credentials') return; // these keys are not returned by the API + if (type === 'gcp') { + // same issues noted in wif enterprise tests with how toggle.hbs passes in name vs how formField input passes in attr to data test selector + if (key === 'configTtl') { + assert + .dom(GENERAL.ttl.input('Config TTL')) + .hasValue('100', `${key} for ${type}: has the expected value set on the config`); + } + if (key === 'maxTtl') { + assert + .dom(GENERAL.ttl.input('Max TTL')) + .hasValue('101', `${key} for ${type}: has the expected value set on the config`); + } + } else { + assert + .dom(GENERAL.inputByAttr(key)) + .hasValue( + this.mountConfigModel[key], + `${key} for ${type}: has the expected value set on the config` + ); + } } }); }