mirror of
https://github.com/hashicorp/vault.git
synced 2026-05-05 04:16:31 +02:00
[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:
parent
59f2127d04
commit
23ab4d924c
@ -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`,
|
||||
};
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}}
|
||||
@ -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';
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}}
|
||||
/>
|
||||
@ -6,4 +6,5 @@
|
||||
<Secrets::Page::Destinations::Destination::Secrets
|
||||
@destination={{this.model.destination}}
|
||||
@associations={{this.model.associations}}
|
||||
@capabilities={{this.model.capabilities}}
|
||||
/>
|
||||
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
80
ui/tests/helpers/sync/setup-hooks.js
Normal file
80
ui/tests/helpers/sync/setup-hooks.js
Normal 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');
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
48
ui/types/vault/sync.d.ts
vendored
48
ui/types/vault/sync.d.ts
vendored
@ -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;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user