From 23ab4d924cc8f685cfe46ee264da96e8fa983ca2 Mon Sep 17 00:00:00 2001 From: Jordan Reimer Date: Thu, 8 May 2025 14:31:01 -0600 Subject: [PATCH] [UI] Ember Data Migration - Sync Details/Secrets (#30554) * more updates to api-client for sync * updates sync destination-header component to use api service * updates to sync types * updates sync destination route to use api service * updates sync destination mirage factory and handler * refactors sync setup-models test helper and removes store * refactors sync destination details route to function with api service data * refactors sync destination secrets route to function with api service data * adds sync destination edit route --- ui/app/utils/constants/capabilities.ts | 1 + .../components/secrets/destination-header.hbs | 10 +- .../components/secrets/destination-header.ts | 31 ++++- .../page/destinations/destination/details.hbs | 15 +-- .../page/destinations/destination/details.ts | 59 ++++++++- .../page/destinations/destination/secrets.hbs | 63 ++++----- .../page/destinations/destination/secrets.ts | 33 +++-- .../page/destinations/destination/sync.hbs | 2 +- .../secrets/destinations/destination.ts | 47 +++++-- .../secrets/destinations/destination/edit.ts | 26 ++++ .../destinations/destination/secrets.ts | 56 ++++---- .../destinations/destination/details.hbs | 5 +- .../destinations/destination/secrets.hbs | 1 + ui/mirage/factories/sync-destination.js | 30 ++--- ui/mirage/handlers/sync.js | 22 +++- ui/tests/helpers/sync/setup-hooks.js | 80 ++++++++++++ ui/tests/helpers/sync/setup-models.js | 53 -------- .../sync/secrets/destination-header-test.js | 65 +++++---- .../destinations/destination/details-test.js | 123 +++++++++--------- .../destinations/destination/secrets-test.js | 23 ++-- .../destinations/destination/sync-test.js | 16 +-- ui/types/vault/sync.d.ts | 48 ++++++- 22 files changed, 519 insertions(+), 290 deletions(-) create mode 100644 ui/lib/sync/addon/routes/secrets/destinations/destination/edit.ts create mode 100644 ui/tests/helpers/sync/setup-hooks.js delete mode 100644 ui/tests/helpers/sync/setup-models.js diff --git a/ui/app/utils/constants/capabilities.ts b/ui/app/utils/constants/capabilities.ts index af986c7425..5513ab322e 100644 --- a/ui/app/utils/constants/capabilities.ts +++ b/ui/app/utils/constants/capabilities.ts @@ -20,4 +20,5 @@ export const PATH_MAP = { syncActivate: apiPath`sys/activation-flags/secrets-sync/activate`, syncDestination: apiPath`sys/sync/destinations/${'type'}/${'name'}`, syncSetAssociation: apiPath`sys/sync/destinations/${'type'}/${'name'}/associations/set`, + syncRemoveAssociation: apiPath`sys/sync/destinations/${'type'}/${'name'}/associations/remove`, }; diff --git a/ui/lib/sync/addon/components/secrets/destination-header.hbs b/ui/lib/sync/addon/components/secrets/destination-header.hbs index c8c10e5e02..63a2c2f840 100644 --- a/ui/lib/sync/addon/components/secrets/destination-header.hbs +++ b/ui/lib/sync/addon/components/secrets/destination-header.hbs @@ -4,7 +4,7 @@ }} {{/if}} - {{#if @destination.canDelete}} + {{#if (has-capability @capabilities "delete" pathKey="syncDestination" params=@destination)}} - {{#if (or @destination.canSync @destination.canEdit)}} + {{#if (or this.showSyncBtn this.showEditBtn)}}
{{/if}} {{/if}} - {{#if @destination.canSync}} + {{#if this.showSyncBtn}} {{/if}} - {{#if @destination.canEdit}} + {{#if this.showEditBtn}} { @service('app-router') declare readonly router: RouterService; @service declare readonly pagination: PaginationService; @service declare readonly flashMessages: FlashMessageService; + @service declare readonly api: ApiService; + @service declare readonly capabilities: CapabilitiesService; + + get showSyncBtn() { + const { destination, capabilities } = this.args; + const path = this.capabilities.pathFor('syncSetAssociation', destination); + return capabilities[path]?.canUpdate && !destination.purgeInitiatedAt; + } + + get showEditBtn() { + const { destination, capabilities } = this.args; + const path = this.capabilities.pathFor('syncDestination', destination); + return capabilities[path]?.canUpdate && !destination.purgeInitiatedAt; + } @action async deleteDestination() { try { const { destination } = this.args; const message = `Destination ${destination.name} has been queued for deletion.`; - await destination.destroyRecord(); - this.pagination.clearDataset('sync/destination'); + const method = apiMethodResolver('delete', destination.type); + await this.api.sys[method](destination.name, {}); this.router.transitionTo('vault.cluster.sync.secrets.overview'); this.flashMessages.success(message); } catch (error) { - this.flashMessages.danger(`Error deleting destination \n ${errorMessage(error)}`); + const { message } = await this.api.parseError(error); + this.flashMessages.danger(`Error deleting destination \n ${message}`); } } } diff --git a/ui/lib/sync/addon/components/secrets/page/destinations/destination/details.hbs b/ui/lib/sync/addon/components/secrets/page/destinations/destination/details.hbs index 8c20f69599..123e5ec345 100644 --- a/ui/lib/sync/addon/components/secrets/page/destinations/destination/details.hbs +++ b/ui/lib/sync/addon/components/secrets/page/destinations/destination/details.hbs @@ -3,15 +3,14 @@ SPDX-License-Identifier: BUSL-1.1 }} - - -{{#each @destination.formFields as |field|}} - {{#let (get @destination field.name) as |fieldValue|}} - {{#if (includes field.name @destination.maskedParams)}} - + +{{#each this.displayFields as |field|}} + {{#let (get @destination field) as |fieldValue|}} + {{#if (this.isMasked field)}} + - {{else if (eq field.name "customTags")}} + {{else if (eq field "options.customTags")}} {{#unless (is-empty-value fieldValue)}} Custom tags @@ -21,7 +20,7 @@ {{/each-in}} {{else}} - + {{/if}} {{/let}} {{/each}} \ No newline at end of file diff --git a/ui/lib/sync/addon/components/secrets/page/destinations/destination/details.ts b/ui/lib/sync/addon/components/secrets/page/destinations/destination/details.ts index 3e70f12f3b..a842c992e3 100644 --- a/ui/lib/sync/addon/components/secrets/page/destinations/destination/details.ts +++ b/ui/lib/sync/addon/components/secrets/page/destinations/destination/details.ts @@ -4,13 +4,68 @@ */ import Component from '@glimmer/component'; +import { findDestination } from 'core/helpers/sync-destinations'; +import { toLabel } from 'core/helpers/to-label'; + +import type { Destination } from 'vault/sync'; +import type { CapabilitiesMap } from 'vault/app-types'; -import type SyncDestinationModel from 'vault/models/sync/destination'; interface Args { - destination: SyncDestinationModel; + destination: Destination; + capabilities: CapabilitiesMap; } export default class DestinationDetailsPage extends Component { + connectionDetailsMap = { + 'aws-sm': ['region', 'accessKeyId', 'secretAccessKey', 'roleArn', 'externalId'], + 'azure-kv': ['keyVaultUri', 'tenantId', 'cloud', 'clientId', 'clientSecret'], + 'gcp-sm': ['projectId', 'credentials'], + gh: ['repositoryOwner', 'repositoryName', 'accessToken'], + 'vercel-project': ['accessToken', 'projectId', 'teamId', 'deploymentEnvironments'], + }; + + get displayFields() { + const { destination } = this.args; + const type = destination.type as keyof typeof this.connectionDetailsMap; + const connectionDetails = this.connectionDetailsMap[type].map((field) => `connectionDetails.${field}`); + const fields = ['name', ...connectionDetails, 'options.granularityLevel', 'options.secretNameTemplate']; + + if (!['gh', 'vercel-project'].includes(type)) { + fields.push('options.customTags'); + } + + return fields; + } + + // remove connectionDetails or options from the field name + fieldName(field: string) { + return field.replace(/(connectionDetails|options)\./, ''); + } + + fieldLabel = (field: string) => { + const fieldName = this.fieldName(field); + // some fields have a specific label that cannot be converted from key name + const customLabel = { + granularityLevel: 'Secret sync granularity', + accessKeyId: 'Access key ID', + roleArn: 'Role ARN', + externalId: 'External ID', + keyVaultUri: 'Key Vault URI', + clientId: 'Client ID', + tenantId: 'Tenant ID', + projectId: 'Project ID', + credentials: 'JSON credentials', + teamId: 'Team ID', + }[fieldName]; + + return customLabel || toLabel([fieldName]); + }; + + isMasked = (field: string) => { + const { maskedParams = [] } = findDestination(this.args.destination.type) || {}; + return maskedParams.includes(this.fieldName(field)); + }; + credentialValue = (value: string) => { // if this value is empty, a destination uses globally set environment variables return value ? 'Destination credentials added' : 'Using environment variable'; diff --git a/ui/lib/sync/addon/components/secrets/page/destinations/destination/secrets.hbs b/ui/lib/sync/addon/components/secrets/page/destinations/destination/secrets.hbs index 82a4386994..8b3f4fe8c5 100644 --- a/ui/lib/sync/addon/components/secrets/page/destinations/destination/secrets.hbs +++ b/ui/lib/sync/addon/components/secrets/page/destinations/destination/secrets.hbs @@ -3,7 +3,11 @@ SPDX-License-Identifier: BUSL-1.1 }} - + {{#if @associations.meta.filteredTotal}}
@@ -19,7 +23,9 @@ class="has-text-black has-text-weight-semibold" @route="kvSecretOverview" @models={{array association.mount association.secretName}} - >{{association.secretName}} + > + {{association.secretName}} + {{#if association.subKey}} {{/if}} @@ -41,34 +47,33 @@ @hasChevron={{false}} data-test-popup-menu-trigger /> - {{#if (or association.setAssociationPath.isLoading association.removeAssociationPath.isLoading)}} - - - - {{else}} - {{#if (eq @destination.granularity "secret-key")}} - - - {{/if}} - {{#if association.canSync}} - - Sync now - {{/if}} + {{#if (eq @destination.options.granularityLevel "secret-key")}} + + + {{/if}} + {{#if (has-capability @capabilities "update" pathKey="syncSetAssociation" params=@destination)}} + + Sync now + + {{/if}} + + View secret + + {{#if (has-capability @capabilities "update" pathKey="syncRemoveAssociation" params=@destination)}} View secret - {{#if association.canUnsync}} - Unsync - {{/if}} + data-test-association-action="unsync" + @color="critical" + {{on "click" (fn (mut this.secretToUnsync) association)}} + > + Unsync + {{/if}} diff --git a/ui/lib/sync/addon/components/secrets/page/destinations/destination/secrets.ts b/ui/lib/sync/addon/components/secrets/page/destinations/destination/secrets.ts index abc369095f..517554e19d 100644 --- a/ui/lib/sync/addon/components/secrets/page/destinations/destination/secrets.ts +++ b/ui/lib/sync/addon/components/secrets/page/destinations/destination/secrets.ts @@ -8,26 +8,25 @@ import { service } from '@ember/service'; import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; import { getOwner } from '@ember/owner'; -import errorMessage from 'vault/utils/error-message'; -import SyncDestinationModel from 'vault/vault/models/sync/destination'; -import type SyncAssociationModel from 'vault/vault/models/sync/association'; import type RouterService from '@ember/routing/router-service'; -import type PaginationService from 'vault/services/pagination'; +import type ApiService from 'vault/services/api'; import type FlashMessageService from 'vault/services/flash-messages'; -import type { EngineOwner } from 'vault/vault/app-types'; +import type { EngineOwner, CapabilitiesMap } from 'vault/app-types'; +import type { Destination, AssociatedSecret } from 'vault/sync'; interface Args { - destination: SyncDestinationModel; - associations: Array; + destination: Destination; + associations: AssociatedSecret[]; + capabilities: CapabilitiesMap; } export default class SyncSecretsDestinationsPageComponent extends Component { @service('app-router') declare readonly router: RouterService; - @service declare readonly pagination: PaginationService; + @service declare readonly api: ApiService; @service declare readonly flashMessages: FlashMessageService; - @tracked secretToUnsync: SyncAssociationModel | null = null; + @tracked secretToUnsync: AssociatedSecret | null = null; get mountPoint(): string { const owner = getOwner(this) as EngineOwner; @@ -41,7 +40,6 @@ export default class SyncSecretsDestinationsPageComponent extends Component ({ + destinationName: storeName, + destinationType: storeType, + ...association, + })); + + return { destination, - associations: this.pagination.lazyPaginatedQuery('sync/association', { - responsePath: 'data.keys', - page: Number(params.page) || 1, - destinationType: destination.type, - destinationName: destination.name, - }), - }); + capabilities, + associations: paginate(associations, { page: Number(page) || 1 }), + }; } - resetController(controller: SyncDestinationSecretsController, isExiting: boolean) { + resetController(controller: Controller, isExiting: boolean) { if (isExiting) { controller.set('page', undefined); } diff --git a/ui/lib/sync/addon/templates/secrets/destinations/destination/details.hbs b/ui/lib/sync/addon/templates/secrets/destinations/destination/details.hbs index a707c2c2c7..1d1cf25772 100644 --- a/ui/lib/sync/addon/templates/secrets/destinations/destination/details.hbs +++ b/ui/lib/sync/addon/templates/secrets/destinations/destination/details.hbs @@ -3,4 +3,7 @@ SPDX-License-Identifier: BUSL-1.1 }} - \ No newline at end of file + \ No newline at end of file diff --git a/ui/lib/sync/addon/templates/secrets/destinations/destination/secrets.hbs b/ui/lib/sync/addon/templates/secrets/destinations/destination/secrets.hbs index e0cde1eaf1..b18629c017 100644 --- a/ui/lib/sync/addon/templates/secrets/destinations/destination/secrets.hbs +++ b/ui/lib/sync/addon/templates/secrets/destinations/destination/secrets.hbs @@ -6,4 +6,5 @@ \ No newline at end of file diff --git a/ui/mirage/factories/sync-destination.js b/ui/mirage/factories/sync-destination.js index bc812fe780..48626028e1 100644 --- a/ui/mirage/factories/sync-destination.js +++ b/ui/mirage/factories/sync-destination.js @@ -5,6 +5,15 @@ import { Factory, trait } from 'miragejs'; +const options = { + granularity: 'secret-path', // default varies per destination, but setting all as secret-path so edit test loop updates each to 'secret-key' + secret_name_template: 'vault/{{ .MountAccessor }}/{{ .SecretPath }}', +}; +const optionsWithTags = { + ...options, + custom_tags: { foo: 'bar' }, +}; + export default Factory.extend({ ['aws-sm']: trait({ type: 'aws-sm', @@ -16,35 +25,28 @@ export default Factory.extend({ role_arn: 'test-role', external_id: 'id12345', // options - granularity: 'secret-path', // default varies per destination, but setting all as secret-path so edit test loop updates each to 'secret-key' - secret_name_template: 'vault-{{ .MountAccessor }}-{{ .SecretPath }}', - custom_tags: { foo: 'bar' }, + ...optionsWithTags, }), ['azure-kv']: trait({ type: 'azure-kv', name: 'destination-azure', // connection_details key_vault_uri: 'https://keyvault-1234abcd.vault.azure.net', - subscription_id: 'subscription-id', tenant_id: 'tenant-id', client_id: 'azure-client-id', client_secret: '*****', cloud: 'Azure Public Cloud', // options - granularity: 'secret-path', - secret_name_template: 'vault-{{ .MountAccessor }}-{{ .SecretPath }}', - custom_tags: { foo: 'bar' }, + ...optionsWithTags, }), ['gcp-sm']: trait({ type: 'gcp-sm', name: 'destination-gcp', - project_id: 'id12345', // connection_details credentials: '*****', + project_id: 'id12345', // options - granularity: 'secret-path', - secret_name_template: 'vault-{{ .MountAccessor }}-{{ .SecretPath }}', - custom_tags: { foo: 'bar' }, + ...optionsWithTags, }), gh: trait({ type: 'gh', @@ -54,8 +56,7 @@ export default Factory.extend({ repository_owner: 'my-organization-or-username', repository_name: 'my-repository', // options - granularity: 'secret-path', - secret_name_template: 'vault-{{ .MountAccessor }}-{{ .SecretPath }}', + ...options, }), ['vercel-project']: trait({ type: 'vercel-project', @@ -66,7 +67,6 @@ export default Factory.extend({ team_id: 'team_12345', deployment_environments: ['development', 'preview'], // 'production' is also an option, but left out for testing to assert form changes value // options - granularity: 'secret-path', - secret_name_template: 'vault-{{ .MountAccessor }}-{{ .SecretPath }}', + ...options, }), }); diff --git a/ui/mirage/handlers/sync.js b/ui/mirage/handlers/sync.js index 2a066e2260..baf22c3db3 100644 --- a/ui/mirage/handlers/sync.js +++ b/ui/mirage/handlers/sync.js @@ -134,12 +134,30 @@ export default function (server) { const destinationResponse = (record) => { delete record.id; - const { name, type, ...connection_details } = record; + + const { + name, + type, + granularity, + secret_name_template, + custom_tags, + purge_initiated_at, + purge_error, + ...connection_details + } = record; + return { data: { - connection_details, name, type, + connection_details, + options: { + granularity_level: granularity, + secret_name_template, + custom_tags, + }, + purge_initiated_at, + purge_error, }, }; }; diff --git a/ui/tests/helpers/sync/setup-hooks.js b/ui/tests/helpers/sync/setup-hooks.js new file mode 100644 index 0000000000..5e52d4b438 --- /dev/null +++ b/ui/tests/helpers/sync/setup-hooks.js @@ -0,0 +1,80 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ +import camelizeKeys from 'vault/utils/camelize-object-keys'; + +// creates destination and association model for use in sync integration tests +// ensure that setupMirage is used prior to setupModels since this.server is used +export function setupDataStubs(hooks) { + hooks.beforeEach(function () { + // most tests are good with the default data generated here + // allow for this to be overridden to test different types + this.setupStubsForType = (destType) => { + const { + id, // eslint-disable-line no-unused-vars + name, + type, + granularity, + secret_name_template, + custom_tags, + purge_initiated_at, + purge_error, + ...connection_details + } = this.server.create('sync-destination', destType); + + this.destination = { + name, + type, + connectionDetails: camelizeKeys(connection_details), + options: { + granularityLevel: granularity, + secretNameTemplate: secret_name_template, + customTags: custom_tags, + }, + purgeInitiatedAt: purge_initiated_at, + purgeError: purge_error, + }; + + this.destinations = [this.destination]; + this.destinations.meta = { + filteredTotal: this.destinations.length, + currentPage: 1, + pageSize: 5, + }; + + const association = this.server.create('sync-association', { + type: this.destination.type, + name: this.destination.name, + mount: 'kv', + secret_name: 'my-secret', + sync_status: 'SYNCED', + updated_at: '2023-09-20T10:51:53.961861096', // removed tz offset so time is consistently displayed + }); + this.association = { + ...camelizeKeys(association), + destinationType: this.destination.type, + destinationName: this.destination.name, + }; + this.associations = [this.association]; + this.associations.meta = { + filteredTotal: this.associations.length, + currentPage: 1, + pageSize: 5, + }; + + const capabilitiesService = this.owner.lookup('service:capabilities'); + const paths = [ + capabilitiesService.pathFor('syncDestination', this.destination), + capabilitiesService.pathFor('syncSetAssociation', this.destination), + capabilitiesService.pathFor('syncRemoveAssociation', this.destination), + ]; + this.capabilities = paths.reduce((obj, path) => { + obj[path] = { canRead: true, canCreate: true, canUpdate: true, canDelete: true }; + return obj; + }, {}); + }; + + this.setupStubsForType('aws-sm'); + }); +} diff --git a/ui/tests/helpers/sync/setup-models.js b/ui/tests/helpers/sync/setup-models.js deleted file mode 100644 index 801861d5b1..0000000000 --- a/ui/tests/helpers/sync/setup-models.js +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -// creates destination and association model for use in sync integration tests -// ensure that setupMirage is used prior to setupModels since this.server is used -export function setupModels(hooks) { - hooks.beforeEach(function () { - this.store = this.owner.lookup('service:store'); - - const destination = this.server.create('sync-destination', 'aws-sm', { name: 'us-west-1' }); - const destinationModelName = 'sync/destinations/aws-sm'; - this.store.pushPayload(destinationModelName, { - modelName: destinationModelName, - ...destination, - id: destination.name, - }); - this.destination = this.store.peekRecord(destinationModelName, destination.name); - this.destinations = this.store.peekAll(destinationModelName); - this.destinations.meta = { - filteredTotal: this.destinations.length, - currentPage: 1, - pageSize: 5, - }; - - const association = this.server.create('sync-association', { - type: 'aws-sm', - name: 'us-west-1', - mount: 'kv', - secret_name: 'my-secret', - sync_status: 'SYNCED', - updated_at: '2023-09-20T10:51:53.961861096', // removed tz offset so time is consistently displayed - }); - const associationModelName = 'sync/association'; - const associationId = `${association.mount}/${association.secret_name}`; - this.store.pushPayload(associationModelName, { - modelName: associationModelName, - ...association, - destinationType: 'aws-sm', - destinationName: 'us-west-1', - id: associationId, - }); - - this.association = this.store.peekRecord(associationModelName, associationId); - this.associations = this.store.peekAll(associationModelName); - this.associations.meta = { - filteredTotal: this.associations.length, - currentPage: 1, - pageSize: 5, - }; - }); -} diff --git a/ui/tests/integration/components/sync/secrets/destination-header-test.js b/ui/tests/integration/components/sync/secrets/destination-header-test.js index 07e7ac0b15..960e6bfdc8 100644 --- a/ui/tests/integration/components/sync/secrets/destination-header-test.js +++ b/ui/tests/integration/components/sync/secrets/destination-header-test.js @@ -7,10 +7,9 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import { setupEngine } from 'ember-engines/test-support'; import { setupMirage } from 'ember-cli-mirage/test-support'; -import { setupModels } from 'vault/tests/helpers/sync/setup-models'; +import { setupDataStubs } from 'vault/tests/helpers/sync/setup-hooks'; import hbs from 'htmlbars-inline-precompile'; -import { click, fillIn, render, settled } from '@ember/test-helpers'; -import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs'; +import { click, fillIn, render } from '@ember/test-helpers'; import { PAGE } from 'vault/tests/helpers/sync/sync-selectors'; import sinon from 'sinon'; @@ -18,62 +17,65 @@ module('Integration | Component | sync | Secrets::DestinationHeader', function ( setupRenderingTest(hooks); setupEngine(hooks, 'sync'); setupMirage(hooks); - setupModels(hooks); + setupDataStubs(hooks); hooks.beforeEach(async function () { - this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub()); + this.refreshList = sinon.stub(); - await render(hbs``, { - owner: this.engine, - }); + this.renderComponent = () => + render( + hbs``, + { + owner: this.engine, + } + ); }); test('it should render SyncHeader component', async function (assert) { - assert.dom(PAGE.title).includesText('us-west-1', 'SyncHeader component renders'); + await this.renderComponent(); + assert.dom(PAGE.title).includesText('destination-aws', 'SyncHeader component renders'); }); test('it should render tabs', async function (assert) { + await this.renderComponent(); assert.dom(PAGE.tab('Secrets')).hasText('Secrets', 'Secrets tab renders'); assert.dom(PAGE.tab('Details')).hasText('Details', 'Details tab renders'); }); test('it should render toolbar', async function (assert) { + await this.renderComponent(); ['Delete destination', 'Sync secrets', 'Edit destination'].forEach((btn) => { assert.dom(PAGE.toolbar(btn)).hasText(btn, `${btn} toolbar action renders`); }); }); test('it should delete destination', async function (assert) { - assert.expect(3); + assert.expect(2); const transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo'); - const clearDatasetStub = sinon.stub(this.owner.lookup('service:pagination'), 'clearDataset'); - this.server.delete('/sys/sync/destinations/aws-sm/us-west-1', () => { + this.server.delete('/sys/sync/destinations/aws-sm/destination-aws', () => { assert.ok(true, 'Request made to delete destination'); return {}; }); + await this.renderComponent(); await click(PAGE.toolbar('Delete destination')); await fillIn(PAGE.confirmModalInput, 'DELETE'); await click(PAGE.confirmButton); - assert.propEqual( - transitionStub.lastCall.args, - ['vault.cluster.sync.secrets.overview'], + assert.true( + transitionStub.calledWith('vault.cluster.sync.secrets.overview'), 'Transition is triggered on delete success' ); - assert.propEqual( - clearDatasetStub.lastCall.args, - ['sync/destination'], - 'Store dataset is cleared on delete success' - ); }); test('it should render delete progress banner and hide actions', async function (assert) { assert.expect(5); - this.destination.set('purgeInitiatedAt', '2024-01-09T16:54:28.463879'); - await settled(); + + this.destination.purgeInitiatedAt = '2024-01-09T16:54:28.463879'; + + await this.renderComponent(); assert .dom(PAGE.destinations.deleteBanner) .hasText( @@ -89,9 +91,11 @@ module('Integration | Component | sync | Secrets::DestinationHeader', function ( test('it should render delete error banner', async function (assert) { assert.expect(2); - this.destination.set('purgeInitiatedAt', '2024-01-09T16:54:28.463879'); - this.destination.set('purgeError', 'oh no! a problem occurred!'); - await settled(); + + this.destination.purgeInitiatedAt = '2024-01-09T16:54:28.463879'; + this.destination.purgeError = 'oh no! a problem occurred!'; + + await this.renderComponent(); assert .dom(PAGE.destinations.deleteBanner) .hasText( @@ -106,15 +110,8 @@ module('Integration | Component | sync | Secrets::DestinationHeader', function ( test('it should render refresh list button', async function (assert) { assert.expect(1); - this.refreshList = () => assert.ok(true, 'Refresh list callback fires'); - - await render( - hbs``, - { - owner: this.engine, - } - ); - + await this.renderComponent(); await click(PAGE.associations.list.refresh); + assert.true(this.refreshList.calledOnce, 'Refresh list action is triggered'); }); }); diff --git a/ui/tests/integration/components/sync/secrets/page/destinations/destination/details-test.js b/ui/tests/integration/components/sync/secrets/page/destinations/destination/details-test.js index 95e6a79fc8..507b7d8e99 100644 --- a/ui/tests/integration/components/sync/secrets/page/destinations/destination/details-test.js +++ b/ui/tests/integration/components/sync/secrets/page/destinations/destination/details-test.js @@ -10,9 +10,9 @@ import { setupMirage } from 'ember-cli-mirage/test-support'; import hbs from 'htmlbars-inline-precompile'; import { render } from '@ember/test-helpers'; import { PAGE } from 'vault/tests/helpers/sync/sync-selectors'; -import { syncDestinations } from 'vault/helpers/sync-destinations'; +import { syncDestinations, findDestination } from 'vault/helpers/sync-destinations'; import { toLabel } from 'vault/helpers/to-label'; -import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs'; +import { setupDataStubs } from 'vault/tests/helpers/sync/setup-hooks'; const SYNC_DESTINATIONS = syncDestinations(); module( @@ -21,15 +21,12 @@ module( setupRenderingTest(hooks); setupEngine(hooks, 'sync'); setupMirage(hooks); + setupDataStubs(hooks); hooks.beforeEach(function () { - this.store = this.owner.lookup('service:store'); - - this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub()); - - this.renderFormComponent = () => { + this.renderComponent = () => { return render( - hbs` `, + hbs` `, { owner: this.engine } ); }; @@ -37,18 +34,8 @@ module( test('it renders toolbar with actions', async function (assert) { assert.expect(3); - const type = SYNC_DESTINATIONS[0].type; - const data = this.server.create('sync-destination', type); - const id = `${type}/${data.name}`; - data.id = id; - this.store.pushPayload(`sync/destinations/${type}`, { - modelName: `sync/destinations/${type}`, - ...data, - }); - this.model = this.store.peekRecord(`sync/destinations/${type}`, id); - - await this.renderFormComponent(); + await this.renderComponent(); assert.dom(PAGE.toolbar('Delete destination')).exists(); assert.dom(PAGE.toolbar('Sync secrets')).exists(); @@ -60,68 +47,84 @@ module( const { type } = destination; module(`destination: ${type}`, function (hooks) { hooks.beforeEach(function () { - const data = this.server.create('sync-destination', type); + this.setupStubsForType(type); - const id = `${type}/${data.name}`; - data.id = id; - this.store.pushPayload(`sync/destinations/${type}`, { - modelName: `sync/destinations/${type}`, - ...data, - }); - this.model = this.store.peekRecord(`sync/destinations/${type}`, id); - const { maskedParams } = this.model; - this.maskedAttrs = this.model.formFields.filter((attr) => maskedParams.includes(attr.name)); - this.unmaskedAttrs = this.model.formFields.filter((attr) => !maskedParams.includes(attr.name)); + const { name, connectionDetails, options } = this.destination; + this.details = { name, ...connectionDetails, ...options }; + this.fields = Object.keys(this.details).reduce((arr, key) => { + const noCustomTags = ['gh', 'vercel-project'].includes(type) && key === 'customTags'; + return noCustomTags ? arr : [...arr, key]; + }, []); + + const { maskedParams } = findDestination(type); + this.maskedParams = maskedParams; + + this.getLabel = (field) => { + const customLabel = { + granularityLevel: 'Secret sync granularity', + accessKeyId: 'Access key ID', + roleArn: 'Role ARN', + externalId: 'External ID', + keyVaultUri: 'Key Vault URI', + clientId: 'Client ID', + tenantId: 'Tenant ID', + projectId: 'Project ID', + credentials: 'JSON credentials', + teamId: 'Team ID', + }[field]; + + return customLabel || toLabel([field]); + }; }); test('it renders destination details with connection_details and options', async function (assert) { - assert.expect(this.model.formFields.length); + assert.expect(this.fields.length); - await this.renderFormComponent(); + await this.renderComponent(); - // these values are returned by the API masked: '*****' - this.maskedAttrs.forEach((attr) => { - const label = attr.options?.label || toLabel([attr.name]); - assert.dom(PAGE.infoRowValue(label)).hasText('Destination credentials added'); - }); - - // assert the remaining model attributes render - this.unmaskedAttrs.forEach(({ name, options, type }) => { - let label, value; - if (type === 'object') { - [label] = Object.keys(this.model[name]); - [value] = Object.values(this.model[name]); + this.fields.forEach((field) => { + if (this.maskedParams.includes(field)) { + // these values are returned by the API masked: '*****' + const label = this.getLabel(field); + assert.dom(PAGE.infoRowValue(label)).hasText('Destination credentials added'); } else { - label = options.label || toLabel([name]); - value = Array.isArray(this.model[name]) ? this.model[name].join(',') : this.model[name]; + // assert the remaining model attributes render + const fieldValue = this.details[field]; + let label, value; + if (field === 'customTags') { + [label] = Object.keys(fieldValue); + [value] = Object.values(fieldValue); + } else { + label = this.getLabel(field); + value = Array.isArray(fieldValue) ? fieldValue.join(',') : fieldValue; + } + assert.dom(PAGE.infoRowValue(label)).hasText(value); } - assert.dom(PAGE.infoRowValue(label)).hasText(value); }); }); test('it renders destination details without connection_details or options', async function (assert) { - assert.expect(this.maskedAttrs.length + 4); + assert.expect(this.maskedParams.length + 4); - this.maskedAttrs.forEach((attr) => { + this.maskedParams.forEach((param) => { // these values are undefined when environment variables are set - this.model[attr.name] = undefined; + this.destination.connectionDetails[param] = undefined; }); - // assert custom tags section header does not render - if (this.model?.get('customTags')) { - this.model['customTags'] = undefined; - } - await this.renderFormComponent(); + // assert custom tags section header does not render + this.destination.options.customTags = undefined; + + await this.renderComponent(); assert .dom(PAGE.destinations.details.sectionHeader) .doesNotExist('does not render Custom tags header'); - assert.dom(PAGE.title).hasTextContaining(this.model.name); - assert.dom(PAGE.icon(this.model.icon)).exists(); - assert.dom(PAGE.infoRowValue('Name')).hasText(this.model.name); + assert.dom(PAGE.title).hasTextContaining(this.destination.name); + assert.dom(PAGE.icon(findDestination(destination.type).icon)).exists(); + assert.dom(PAGE.infoRowValue('Name')).hasText(this.destination.name); - this.maskedAttrs.forEach((attr) => { - const label = attr.options?.label || toLabel([attr.name]); + this.maskedParams.forEach((param) => { + const label = this.getLabel(param); assert.dom(PAGE.infoRowValue(label)).hasText('Using environment variable'); }); }); diff --git a/ui/tests/integration/components/sync/secrets/page/destinations/destination/secrets-test.js b/ui/tests/integration/components/sync/secrets/page/destinations/destination/secrets-test.js index 392c6295bf..c0a62db639 100644 --- a/ui/tests/integration/components/sync/secrets/page/destinations/destination/secrets-test.js +++ b/ui/tests/integration/components/sync/secrets/page/destinations/destination/secrets-test.js @@ -8,13 +8,13 @@ import { setupRenderingTest } from 'ember-qunit'; import { setupEngine } from 'ember-engines/test-support'; import { setupMirage } from 'ember-cli-mirage/test-support'; import syncHandler from 'vault/mirage/handlers/sync'; -import { setupModels } from 'vault/tests/helpers/sync/setup-models'; +import { setupDataStubs } from 'vault/tests/helpers/sync/setup-hooks'; import hbs from 'htmlbars-inline-precompile'; import { click, render } from '@ember/test-helpers'; import sinon from 'sinon'; +import { Response } from 'miragejs'; import { PAGE } from 'vault/tests/helpers/sync/sync-selectors'; -import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs'; module( 'Integration | Component | sync | Secrets::Page::Destinations::Destination::Secrets', @@ -22,16 +22,16 @@ module( setupRenderingTest(hooks); setupEngine(hooks, 'sync'); setupMirage(hooks); - setupModels(hooks); + setupDataStubs(hooks); hooks.beforeEach(async function () { syncHandler(this.server); - this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub()); sinon.stub(this.owner.lookup('service:router'), 'transitionTo'); await render( hbs` @@ -41,7 +41,7 @@ module( }); test('it should render DestinationHeader component', async function (assert) { - assert.dom(PAGE.title).includesText('us-west-1', 'DestinationHeader component renders'); + assert.dom(PAGE.title).includesText('destination-aws', 'DestinationHeader component renders'); }); test('it should render empty list state', async function (assert) { @@ -68,11 +68,14 @@ module( test('it should render list item menu actions', async function (assert) { assert.expect(5); - this.server.post('/sys/sync/destinations/aws-sm/us-west-1/associations/:action', (schema, req) => { - const { action } = req.params; - const operation = { set: 'sync', remove: 'unsync' }[action] || null; - assert.ok(operation, `Request made to ${operation} secret`); - }); + this.server.post( + '/sys/sync/destinations/aws-sm/destination-aws/associations/:action', + (schema, req) => { + const { action } = req.params; + const operation = { set: 'sync', remove: 'unsync' }[action] || null; + assert.ok(operation, `Request made to ${operation} secret`); + } + ); await click(PAGE.menuTrigger); diff --git a/ui/tests/integration/components/sync/secrets/page/destinations/destination/sync-test.js b/ui/tests/integration/components/sync/secrets/page/destinations/destination/sync-test.js index 88bab1e4bb..b748b24a2b 100644 --- a/ui/tests/integration/components/sync/secrets/page/destinations/destination/sync-test.js +++ b/ui/tests/integration/components/sync/secrets/page/destinations/destination/sync-test.js @@ -7,11 +7,10 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import { setupEngine } from 'ember-engines/test-support'; import { setupMirage } from 'ember-cli-mirage/test-support'; -import { setupModels } from 'vault/tests/helpers/sync/setup-models'; +import { setupDataStubs } from 'vault/tests/helpers/sync/setup-hooks'; import hbs from 'htmlbars-inline-precompile'; import { render, click, fillIn, settled } from '@ember/test-helpers'; import { PAGE } from 'vault/tests/helpers/sync/sync-selectors'; -import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs'; import { selectChoose } from 'ember-power-select/test-support'; import sinon from 'sinon'; import { Response } from 'miragejs'; @@ -23,11 +22,9 @@ module('Integration | Component | sync | Secrets::Page::Destinations::Destinatio setupRenderingTest(hooks); setupEngine(hooks, 'sync'); setupMirage(hooks); - setupModels(hooks); + setupDataStubs(hooks); hooks.beforeEach(async function () { - this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub()); - this.server.get('/sys/internal/ui/mounts', () => ({ data: { secret: { 'my-kv/': { type: 'kv', options: { version: '2' } } } }, })); @@ -38,9 +35,12 @@ module('Integration | Component | sync | Secrets::Page::Destinations::Destinatio data: { keys: ['nested-secret'] }, })); - await render(hbs``, { - owner: this.engine, - }); + await render( + hbs``, + { + owner: this.engine, + } + ); }); test('it should fetch and render kv mounts', async function (assert) { diff --git a/ui/types/vault/sync.d.ts b/ui/types/vault/sync.d.ts index bca6f47216..2cdf0df62f 100644 --- a/ui/types/vault/sync.d.ts +++ b/ui/types/vault/sync.d.ts @@ -12,10 +12,12 @@ export type ListDestination = { }; export type AssociatedSecret = { - accessor: string; + mount: string; secretName: string; syncStatus: string; updatedAt: Date; + destinationType: DestinationType; + destinationName: string; }; export type AssociatedDestination = { @@ -25,12 +27,12 @@ export type AssociatedDestination = { updatedAt: Date; }; -export interface SyncStatus { +export type SyncStatus = { destinationType: string; destinationName: string; syncStatus: string; updatedAt: string; -} +}; export type DestinationMetrics = { icon?: string; @@ -54,3 +56,43 @@ export type DestinationName = | 'Google Secret Manager' | 'Github Actions' | 'Vercel Project'; + +export type Destination = { + name: string; + type: DestinationType; + connectionDetails: DestinationConnectionDetails; + options: DestinationOptions; + // only present if delete action has been initiated + purgeInitiatedAt?: string; + purgeError?: string; +}; + +export type DestinationConnectionDetails = { + // aws-sm + accessKeyId?: string; + secretAccessKey?: string; + region?: string; + // azure-kv + keyVaultUri?: string; + clientId?: string; + clientSecret?: string; + tenantId?: string; + cloud?: string; + // gcp + credentials?: string; + // gh + accessToken?: string; + repositoryOwner?: string; + repositoryName?: string; + // vercel project + accessToken?: string; + projectId?: string; + teamId?: string; + deploymentEnvironments?: array; +}; + +export type DestinationOptions = { + granularity: string; + secretNameTemplate: string; + customTags?: string; +};