[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
This commit is contained in:
Jordan Reimer 2025-05-08 14:31:01 -06:00 committed by GitHub
parent 59f2127d04
commit 23ab4d924c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 519 additions and 290 deletions

View File

@ -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`,
};

View File

@ -4,7 +4,7 @@
}}
<SyncHeader
@icon={{@destination.icon}}
@icon={{get (find-by "type" @destination.type (sync-destinations)) "icon"}}
@title={{@destination.name}}
@breadcrumbs={{array
(hash label="Secrets Sync" route="secrets.overview")
@ -65,7 +65,7 @@
</ToolbarFilters>
{{/if}}
<ToolbarActions>
{{#if @destination.canDelete}}
{{#if (has-capability @capabilities "delete" pathKey="syncDestination" params=@destination)}}
<Hds::Button
data-test-toolbar="Delete destination"
@text="Delete destination"
@ -73,11 +73,11 @@
class="toolbar-button"
{{on "click" (fn (mut this.isDeleteModalOpen) true)}}
/>
{{#if (or @destination.canSync @destination.canEdit)}}
{{#if (or this.showSyncBtn this.showEditBtn)}}
<div class="toolbar-separator"></div>
{{/if}}
{{/if}}
{{#if @destination.canSync}}
{{#if this.showSyncBtn}}
<Hds::Button
data-test-toolbar="Sync secrets"
@text="Sync secrets"
@ -88,7 +88,7 @@
@route="secrets.destinations.destination.sync"
/>
{{/if}}
{{#if @destination.canEdit}}
{{#if this.showEditBtn}}
<Hds::Button
data-test-toolbar="Edit destination"
@text="Edit destination"

View File

@ -6,33 +6,52 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { service } from '@ember/service';
import errorMessage from 'vault/utils/error-message';
import apiMethodResolver from 'sync/utils/api-method-resolver';
import type SyncDestinationModel from 'vault/models/sync/destination';
import type RouterService from '@ember/routing/router-service';
import type PaginationService from 'vault/services/pagination';
import type FlashMessageService from 'vault/services/flash-messages';
import type ApiService from 'vault/services/api';
import type CapabilitiesService from 'vault/services/capabilities';
import type { Destination } from 'vault/sync';
import type { CapabilitiesMap } from 'vault/app-types';
interface Args {
destination: SyncDestinationModel;
destination: Destination;
capabilities: CapabilitiesMap;
}
export default class DestinationsTabsToolbar extends Component<Args> {
@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}`);
}
}
}

View File

@ -3,15 +3,14 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Secrets::DestinationHeader @destination={{@destination}} />
{{#each @destination.formFields as |field|}}
{{#let (get @destination field.name) as |fieldValue|}}
{{#if (includes field.name @destination.maskedParams)}}
<InfoTableRow @label={{or field.options.label (to-label field.name)}}>
<Secrets::DestinationHeader @destination={{@destination}} @capabilities={{@capabilities}} />
{{#each this.displayFields as |field|}}
{{#let (get @destination field) as |fieldValue|}}
{{#if (this.isMasked field)}}
<InfoTableRow @label={{this.fieldLabel field}}>
<Hds::Badge @text={{this.credentialValue fieldValue}} @icon="check-circle" @color="success" />
</InfoTableRow>
{{else if (eq field.name "customTags")}}
{{else if (eq field "options.customTags")}}
{{#unless (is-empty-value fieldValue)}}
<Hds::Text::Display @tag="h3" @size="300" @weight="semibold" class="has-top-margin-l" data-test-section-header>
Custom tags
@ -21,7 +20,7 @@
<InfoTableRow @alwaysRender={{false}} @label={{key}} @value={{value}} />
{{/each-in}}
{{else}}
<InfoTableRow @label={{or field.options.label (to-label field.name)}} @value={{fieldValue}} />
<InfoTableRow @label={{this.fieldLabel field}} @value={{fieldValue}} />
{{/if}}
{{/let}}
{{/each}}

View File

@ -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<Args> {
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';

View File

@ -3,7 +3,11 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Secrets::DestinationHeader @destination={{@destination}} @refreshList={{this.refreshRoute}} />
<Secrets::DestinationHeader
@destination={{@destination}}
@capabilities={{@capabilities}}
@refreshList={{this.refreshRoute}}
/>
{{#if @associations.meta.filteredTotal}}
<div class="has-bottom-margin-s">
@ -19,7 +23,9 @@
class="has-text-black has-text-weight-semibold"
@route="kvSecretOverview"
@models={{array association.mount association.secretName}}
>{{association.secretName}}</LinkToExternal>
>
{{association.secretName}}
</LinkToExternal>
{{#if association.subKey}}
<Hds::Badge @text="secret key: {{association.subKey}}/" />
{{/if}}
@ -41,34 +47,33 @@
@hasChevron={{false}}
data-test-popup-menu-trigger
/>
{{#if (or association.setAssociationPath.isLoading association.removeAssociationPath.isLoading)}}
<dd.Generic class="has-text-center">
<LoadingDropdownOption />
</dd.Generic>
{{else}}
{{#if (eq @destination.granularity "secret-key")}}
<dd.Description
@text='Sync or unsync actions will apply to the secret "{{association.secretName}}" and not this individual key.'
/>
<dd.Separator />
{{/if}}
{{#if association.canSync}}
<dd.Interactive data-test-association-action="sync" {{on "click" (fn this.update association "set")}}>
Sync now</dd.Interactive>
{{/if}}
{{#if (eq @destination.options.granularityLevel "secret-key")}}
<dd.Description
@text='Sync or unsync actions will apply to the secret "{{association.secretName}}" and not this individual key.'
/>
<dd.Separator />
{{/if}}
{{#if (has-capability @capabilities "update" pathKey="syncSetAssociation" params=@destination)}}
<dd.Interactive data-test-association-action="sync" {{on "click" (fn this.update association "set")}}>
Sync now
</dd.Interactive>
{{/if}}
<dd.Interactive
data-test-association-action="view"
@route="kvSecretOverview"
@isRouteExternal={{true}}
@models={{array association.mount association.secretName}}
>
View secret
</dd.Interactive>
{{#if (has-capability @capabilities "update" pathKey="syncRemoveAssociation" params=@destination)}}
<dd.Interactive
data-test-association-action="view"
@route="kvSecretOverview"
@isRouteExternal={{true}}
@models={{array association.mount association.secretName}}
>View secret</dd.Interactive>
{{#if association.canUnsync}}
<dd.Interactive
data-test-association-action="unsync"
@color="critical"
{{on "click" (fn (mut this.secretToUnsync) association)}}
>Unsync</dd.Interactive>
{{/if}}
data-test-association-action="unsync"
@color="critical"
{{on "click" (fn (mut this.secretToUnsync) association)}}
>
Unsync
</dd.Interactive>
{{/if}}
</Hds::Dropdown>
</Item.menu>

View File

@ -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<SyncAssociationModel>;
destination: Destination;
associations: AssociatedSecret[];
capabilities: CapabilitiesMap;
}
export default class SyncSecretsDestinationsPageComponent extends Component<Args> {
@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<Args
@action
refreshRoute() {
// refresh route to update displayed secrets
this.pagination.clearDataset('sync/association');
this.router.transitionTo(
'vault.cluster.sync.secrets.destinations.destination.secrets',
this.args.destination.type,
@ -50,13 +48,22 @@ export default class SyncSecretsDestinationsPageComponent extends Component<Args
}
@action
async update(association: SyncAssociationModel, operation: string) {
async update(association: AssociatedSecret, operation: string) {
try {
await association.save({ adapterOptions: { action: operation } });
const { name, type } = this.args.destination;
const { mount, secretName } = association;
const body = { mount, secretName };
if (operation === 'set') {
await this.api.sys.systemWriteSyncDestinationsTypeNameAssociationsSet(name, type, body);
} else {
await this.api.sys.systemWriteSyncDestinationsTypeNameAssociationsRemove(name, type, body);
}
const action: string = operation === 'set' ? 'Sync' : 'Unsync';
this.flashMessages.success(`${action} operation initiated.`);
} catch (error) {
this.flashMessages.danger(`Sync operation error: \n ${errorMessage(error)}`);
const { message } = await this.api.parseError(error);
this.flashMessages.danger(`Sync operation error: \n ${message}`);
} finally {
this.secretToUnsync = null;
this.refreshRoute();

View File

@ -5,7 +5,7 @@
<SyncHeader
@title="Sync Secrets to {{@destination.name}}"
@icon={{@destination.icon}}
@icon={{get (find-by "type" @destination.type (sync-destinations)) "icon"}}
@breadcrumbs={{array
(hash label="Secrets Sync" route="secrets.overview")
(hash label="Destinations" route="secrets.destinations")

View File

@ -5,29 +5,52 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import apiMethodResolver from 'sync/utils/api-method-resolver';
import type Store from '@ember-data/store';
import type RouterService from '@ember/routing/router-service';
import type FlashMessageService from 'vault/services/flash-messages';
import type Transition from '@ember/routing/transition';
import type SyncDestinationModel from 'vault/models/sync/destination';
import type ApiService from 'vault/services/api';
import type { Destination } from 'vault/sync';
import type CapabilitiesService from 'vault/services/capabilities';
import type { CapabilitiesMap } from 'vault/app-types';
interface RouteParams {
name?: string;
type?: string;
}
type Params = {
name: string;
type: Destination['type'];
};
export type DestinationRouteModel = {
destination: Destination;
capabilities: CapabilitiesMap;
};
export default class SyncSecretsDestinationsDestinationRoute extends Route {
@service declare readonly store: Store;
@service declare readonly api: ApiService;
@service('app-router') declare readonly router: RouterService;
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly capabilities: CapabilitiesService;
model(params: RouteParams) {
const { name = '', type } = params;
return this.store.findRecord(`sync/destinations/${type}`, name);
async model(params: Params) {
const { name, type } = params;
const method = apiMethodResolver('read', type);
const requests = [
this.api.sys[method](name, {}),
this.capabilities.fetch([
this.capabilities.pathFor('syncDestination', { name, type }),
this.capabilities.pathFor('syncSetAssociation', { name, type }),
]),
];
const [destination, capabilities] = await Promise.all(requests);
return {
destination,
capabilities,
};
}
afterModel(model: SyncDestinationModel, transition: Transition) {
afterModel({ destination }: DestinationRouteModel, transition: Transition) {
// handles the case where the user attempts to perform actions on a destination when a purge has been initiated
// editing is available from the list view and syncing secrets is available from the overview
// the list endpoint does not return the full model so we don't have access to purgeInitiatedAt to disable or hide the actions
@ -35,7 +58,7 @@ export default class SyncSecretsDestinationsDestinationRoute extends Route {
const baseRoute = 'vault.cluster.sync.secrets.destinations.destination';
const routes = [`${baseRoute}.edit`, `${baseRoute}.sync`];
const toRoute = transition.to?.name;
if (toRoute && routes.includes(toRoute) && model.purgeInitiatedAt) {
if (toRoute && routes.includes(toRoute) && destination.purgeInitiatedAt) {
const action = transition.to?.localName === 'edit' ? 'Editing a destination' : 'Syncing secrets';
this.flashMessages.info(`${action} is not permitted once a purge has been initiated.`);
this.router.replaceWith('vault.cluster.sync.secrets.destinations.destination.secrets');

View File

@ -0,0 +1,26 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import type Store from '@ember-data/store';
type RouteParams = {
name: string;
type: string;
};
// originally this route was inheriting the model (Ember Data destination model) from the destination parent route
// an explicit route will be necessary since we will be passing in a Form instance to edit
// this will be done in a follow up PR but for now the Ember Data model will be returned to preserver functionality
export default class SyncSecretsDestinationsDestinationEditRoute extends Route {
@service declare readonly store: Store;
model() {
const { name, type } = this.paramsFor('secrets.destinations.destination') as RouteParams;
return this.store.findRecord(`sync/destinations/${type}`, name);
}
}

View File

@ -5,29 +5,18 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { hash } from 'rsvp';
import { paginate } from 'core/utils/paginate-list';
import type PaginationService from 'vault/services/pagination';
import type SyncDestinationModel from 'vault/vault/models/sync/destination';
import type SyncAssociationModel from 'vault/vault/models/sync/association';
import type ApiService from 'vault/services/api';
import type { DestinationRouteModel } from '../destination';
import type Controller from '@ember/controller';
interface SyncDestinationSecretsRouteParams {
type Params = {
page?: string;
}
interface SyncDestinationSecretsRouteModel {
destination: SyncDestinationModel;
associations: SyncAssociationModel[];
}
interface SyncDestinationSecretsController extends Controller {
model: SyncDestinationSecretsRouteModel;
page: number | undefined;
}
};
export default class SyncDestinationSecretsRoute extends Route {
@service declare readonly pagination: PaginationService;
@service declare readonly api: ApiService;
queryParams = {
page: {
@ -35,20 +24,31 @@ export default class SyncDestinationSecretsRoute extends Route {
},
};
model(params: SyncDestinationSecretsRouteParams) {
const destination = this.modelFor('secrets.destinations.destination') as SyncDestinationModel;
return hash({
async model({ page }: Params) {
const { destination, capabilities } = this.modelFor(
'secrets.destinations.destination'
) as DestinationRouteModel;
const {
associatedSecrets = {},
storeName,
storeType,
} = await this.api.sys.systemReadSyncDestinationsTypeNameAssociations(destination.name, destination.type);
const associations = Object.values(associatedSecrets).map((association) => ({
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);
}

View File

@ -3,4 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Secrets::Page::Destinations::Destination::Details @destination={{this.model}} />
<Secrets::Page::Destinations::Destination::Details
@destination={{this.model.destination}}
@capabilities={{this.model.capabilities}}
/>

View File

@ -6,4 +6,5 @@
<Secrets::Page::Destinations::Destination::Secrets
@destination={{this.model.destination}}
@associations={{this.model.associations}}
@capabilities={{this.model.capabilities}}
/>

View File

@ -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,
}),
});

View File

@ -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,
},
};
};

View File

@ -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');
});
}

View File

@ -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,
};
});
}

View File

@ -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`<Secrets::DestinationHeader @destination={{this.destination}} />`, {
owner: this.engine,
});
this.renderComponent = () =>
render(
hbs`<Secrets::DestinationHeader @destination={{this.destination}} @capabilities={{this.capabilities}} @refreshList={{this.refreshList}} />`,
{
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`<Secrets::DestinationHeader @destination={{this.destination}} @refreshList={{this.refreshList}} />`,
{
owner: this.engine,
}
);
await this.renderComponent();
await click(PAGE.associations.list.refresh);
assert.true(this.refreshList.calledOnce, 'Refresh list action is triggered');
});
});

View File

@ -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` <Secrets::Page::Destinations::Destination::Details @destination={{this.model}} />`,
hbs` <Secrets::Page::Destinations::Destination::Details @destination={{this.destination}} @capabilities={{this.capabilities}} />`,
{ 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');
});
});

View File

@ -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`
<Secrets::Page::Destinations::Destination::Secrets
@capabilities={{this.capabilities}}
@destination={{this.destination}}
@associations={{this.associations}}
/>
@ -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);

View File

@ -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`<Secrets::Page::Destinations::Destination::Sync @destination={{this.destination}} />`, {
owner: this.engine,
});
await render(
hbs`<Secrets::Page::Destinations::Destination::Sync @destination={{this.destination}} @capabilities={{this.capabilities}} />`,
{
owner: this.engine,
}
);
});
test('it should fetch and render kv mounts', async function (assert) {

View File

@ -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;
};