Enabling Secrets Sync for HVD (#26841)

* Allow Managed clusters to see Secrets Sync Overview and Sidebar nav (#26649)

* update badge text and allow hvd on secrets sync views

* update logic in Secrets Sync overview and cta for hvd.

* spacing

* rearrange based on pr feedback

* fix return on badgeText and cluster nav test

* fix landing cta tests

* update test to reflect new changes

* moved call to feature-flags from application route to the service to match patterns

* add managed test coverage on overview component test and remove premium feature so cta message appplies to both managed and non-managed clusters

* missed service name and unskip admin test

* clean up

* fix tests

* flags test fix

* Rename isManaged and managedNamespaceRoot (#26697)

* renames

* lowercase HVD to match

* missed some

* test failure

* [Secrets Sync] enable access to Sync clients page for HVD clusters (#26713)

* feat: split client counts navbar into separate component

* acceptance/clients/counts/overview-test: remove tests now covered by int tests

* clients counts route: rename isSecretsSyncActivated to showSecretsSync

* sync clients page: show unactivated state unless sync client history or feature is activated

* client counts navbar: show sync tab only if client history or is /able to be/ activated

* clients overview page: only show sync charts if activated

* fix: rename isManaged to isHvd

* acceptance/counts/overview-test: add HVD tests

* acceptance/counts/overview-test: clean up unused cruft

* aceptance/clients/counts/overview-test: ensure we dont get false negatives

* chore: move Clients::Error to Clients::Counts::Error

* chore: calculate showSecretSync in page component instead of route

* chore: add copyright headers

* acceptance/clients/counts/overview-test: stub activated flags to fix test

* [Secrets sync] update sync test selectors (#26824)

* acceptance/clients/counts/overview-test: use imported test selectors

* general-selectors: add missing emptyStateSubtitle property

* acceptance/clients/counts/sync: nest tests in top level module for easier test runs

* Add permissions check to show/hide activate button (#26840)

* add permissions check to flags service and consume in overview template

* add back missing refresh

* fix test failures

* add test coverage

* clean up

* address flaky test

* grr

* address test failures

* add changelog

* try to fix test failure only on gh

* fix fetch to match previous implementation of feature-flags

* fix failing test

* update comment

---------

Co-authored-by: Noelle Daley <noelledaley@users.noreply.github.com>
Co-authored-by: clairebontempo@gmail.com <clairebontempo@gmail.com>
This commit is contained in:
Angel Garbarino 2024-05-09 15:11:26 -06:00 committed by GitHub
parent a7b6f3490f
commit 077c70fc1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 713 additions and 531 deletions

3
changelog/26841.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:improvement
ui (enterprise): Allow HVD users to access Secrets Sync.
```

View file

@ -133,7 +133,7 @@ export default Component.extend({
// The namespace can be either be passed as a query parameter, or be embedded
// in the state param in the format `<state_id>,ns=<namespace>`. So if
// `namespace` is empty, check for namespace in state as well.
if (namespace === '' || this.flagsService.managedNamespaceRoot) {
if (namespace === '' || this.flagsService.hvdManagedNamespaceRoot) {
const i = state.indexOf(',ns=');
if (i >= 0) {
// ",ns=" is 4 characters

View file

@ -22,7 +22,6 @@ import type {
} from 'core/utils/client-count-utils';
interface Args {
isSecretsSyncActivated?: boolean;
activity: ClientsActivityModel;
versionHistory: ClientsVersionHistoryModel[];
startTimestamp: number;

View file

@ -39,6 +39,7 @@ import { format, isSameMonth } from 'date-fns';
* @param {string} responseTimestamp - ISO timestamp created in serializer to timestamp the response, renders in bottom left corner below attribution chart
* @param {boolean} isHistoricalMonth - when true data is from a single, historical month so side-by-side charts should display for attribution data
* @param {array} upgradesDuringActivity - array of objects containing version history upgrade data
* @param {boolean} isSecretsSyncActivated - boolean to determine if secrets sync is activated
*/
export default class Attribution extends Component {

View file

@ -0,0 +1,31 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<nav class="tabs has-bottom-margin-s" aria-label="navigation for managing client counts">
<ul>
<li>
<LinkTo @route="vault.cluster.clients.counts.overview" data-test-tab="overview">
Overview
</LinkTo>
</li>
<li>
<LinkTo @route="vault.cluster.clients.counts.token" data-test-tab="token">
Entity/Non-entity clients
</LinkTo>
</li>
{{#if @showSecretsSync}}
<li>
<LinkTo @route="vault.cluster.clients.counts.sync" data-test-tab="sync">
Secrets sync clients
</LinkTo>
</li>
{{/if}}
<li>
<LinkTo @route="vault.cluster.clients.counts.acme" data-test-tab="acme">
ACME clients
</LinkTo>
</li>
</ul>
</nav>

View file

@ -51,7 +51,7 @@
{{#if (eq @activity.id "no-data")}}
<Clients::NoData @config={{@config}} @dateRangeMessage={{this.dateRangeMessage}} />
{{else if @activityError}}
<Clients::Error @error={{@activityError}} />
<Clients::Counts::Error @error={{@activityError}} />
{{else}}
{{#if (eq @config.enabled "Off")}}
<Hds::Alert @type="inline" @color="warning" class="has-bottom-margin-s" as |A|>
@ -151,32 +151,7 @@
</Hds::Alert>
{{/if}}
<nav class="tabs has-bottom-margin-s" aria-label="navigation for managing client counts">
<ul>
<li>
<LinkTo @route="vault.cluster.clients.counts.overview" data-test-tab="overview">
Overview
</LinkTo>
</li>
<li>
<LinkTo @route="vault.cluster.clients.counts.token" data-test-tab="token">
Entity/Non-entity clients
</LinkTo>
</li>
{{#if this.version.hasSecretsSync}}
<li>
<LinkTo @route="vault.cluster.clients.counts.sync" data-test-tab="sync">
Secrets sync clients
</LinkTo>
</li>
{{/if}}
<li>
<LinkTo @route="vault.cluster.clients.counts.acme" data-test-tab="acme">
ACME clients
</LinkTo>
</li>
</ul>
</nav>
<Clients::Counts::NavBar @showSecretsSync={{this.showSecretsSync}} />
{{! CLIENT COUNT PAGE COMPONENTS RENDER HERE }}
{{yield}}

View file

@ -12,6 +12,7 @@ import { filterVersionHistory, formatDateObject } from 'core/utils/client-count-
import timestamp from 'core/utils/timestamp';
import type AdapterError from '@ember-data/adapter';
import type FlagsService from 'vault/services/flags';
import type StoreService from 'vault/services/store';
import type VersionService from 'vault/services/version';
import type ClientsActivityModel from 'vault/models/clients/activity';
@ -30,6 +31,7 @@ interface Args {
}
export default class ClientsCountsPageComponent extends Component<Args> {
@service declare readonly flags: FlagsService;
@service declare readonly version: VersionService;
@service declare readonly store: StoreService;
@ -165,6 +167,22 @@ export default class ClientsCountsPageComponent extends Component<Args> {
return activity?.total;
}
get showSecretsSync(): boolean {
const { activity } = this.args;
// if there is any sync client data, show it
if (activity && activity?.total?.secret_syncs > 0) return true;
// otherwise, show the tab based on the cluster type and license
if (this.version.isCommunity) return false;
const isHvd = this.flags.isHvdManaged;
const onLicense = this.version.hasSecretsSync;
// we can't tell if HVD clusters have the feature or not, so we show it by default
// if the cluster is not HVD, show the tab if the feature is on the license
return isHvd || onLicense;
}
@action
onDateChange(dateObject: { dateType: string; monthIdx: number; year: number }) {
const { dateType, monthIdx, year } = dateObject;

View file

@ -4,7 +4,7 @@
~}}
<Clients::RunningTotal
@isSecretsSyncActivated={{@isSecretsSyncActivated}}
@isSecretsSyncActivated={{this.flags.secretsSyncIsActivated}}
@byMonthActivityData={{this.byMonthActivityData}}
@isHistoricalMonth={{and (not this.isCurrentMonth) (not this.isDateRange)}}
@isCurrentMonth={{this.isCurrentMonth}}
@ -15,7 +15,7 @@
/>
{{#if this.hasAttributionData}}
<Clients::Attribution
@isSecretsSyncActivated={{@isSecretsSyncActivated}}
@isSecretsSyncActivated={{this.flags.secretsSyncIsActivated}}
@totalUsageCounts={{this.totalUsageCounts}}
@newUsageCounts={{this.newClientCounts}}
@totalClientAttribution={{this.totalClientAttribution}}

View file

@ -4,5 +4,9 @@
*/
import ActivityComponent from '../activity';
import { service } from '@ember/service';
import type FlagsService from 'vault/services/flags';
export default class ClientsOverviewPageComponent extends ActivityComponent {}
export default class ClientsOverviewPageComponent extends ActivityComponent {
@service declare readonly flags: FlagsService;
}

View file

@ -2,7 +2,7 @@
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{#if @isSecretsSyncActivated}}
{{#if this.flags.secretsSyncIsActivated}}
{{#if (not this.byMonthActivityData)}}
{{! byMonthActivityData is an empty array if there is no monthly data (monthly breakdown was added in 1.11)
this means the user has queried dates before sync clients existed. we render an empty state instead of

View file

@ -4,8 +4,11 @@
*/
import ActivityComponent from '../activity';
import { service } from '@ember/service';
import type FlagsService from 'vault/services/flags';
export default class SyncComponent extends ActivityComponent {
@service declare readonly flags: FlagsService;
title = 'Secrets sync usage';
description =
'This data can be used to understand how many secrets sync clients have been used for this date range. Each Vault secret that is synced to at least one destination counts as one Vault client.';

View file

@ -13,14 +13,12 @@
@text="Secrets Engines"
data-test-sidebar-nav-link="Secrets Engines"
/>
{{#if this.showSync}}
<Nav.Link
@route="vault.cluster.sync"
@text="Secrets Sync"
@badge={{this.syncBadge}}
data-test-sidebar-nav-link="Secrets Sync"
/>
{{/if}}
<Nav.Link
@route="vault.cluster.sync"
@text="Secrets Sync"
@badge={{this.badgeText}}
data-test-sidebar-nav-link="Secrets Sync"
/>
{{#if (has-permission "access")}}
<Nav.Link
@route={{get (route-params-for "access") "route"}}

View file

@ -22,14 +22,15 @@ export default class SidebarNavClusterComponent extends Component {
return this.namespace.inRootNamespace && !this.cluster?.hasChrootNamespace;
}
get showSync() {
// Only show sync if cluster is not managed
return this.flags.managedNamespaceRoot === null;
}
get badgeText() {
const isHvdManaged = this.flags.isHvdManaged;
const onLicense = this.version.hasSecretsSync;
const isEnterprise = this.version.isEnterprise;
get syncBadge() {
if (this.version.isCommunity) return 'Enterprise';
if (!this.version.hasSecretsSync) return 'Premium';
return undefined;
if (isHvdManaged) return 'Plus';
if (isEnterprise && !onLicense) return 'Premium';
if (!isEnterprise) return 'Enterprise';
// no badge for Enterprise clusters with Secrets Sync on their license--the only remaining option.
return '';
}
}

View file

@ -22,13 +22,13 @@ export default Controller.extend({
namespaceQueryParam: alias('clusterController.namespaceQueryParam'),
wrappedToken: alias('vaultController.wrappedToken'),
redirectTo: alias('vaultController.redirectTo'),
managedNamespaceRoot: alias('flagsService.managedNamespaceRoot'),
hvdManagedNamespaceRoot: alias('flagsService.hvdManagedNamespaceRoot'),
authMethod: '',
oidcProvider: '',
get namespaceInput() {
const namespaceQP = this.clusterController.namespaceQueryParam;
if (this.managedNamespaceRoot) {
if (this.hvdManagedNamespaceRoot) {
// When managed, the user isn't allowed to edit the prefix `admin/` for their nested namespace
const split = namespaceQP.split('/');
if (split.length > 1) {
@ -42,8 +42,8 @@ export default Controller.extend({
fullNamespaceFromInput(value) {
const strippedNs = sanitizePath(value);
if (this.managedNamespaceRoot) {
return `${this.managedNamespaceRoot}/${strippedNs}`;
if (this.hvdManagedNamespaceRoot) {
return `${this.hvdManagedNamespaceRoot}/${strippedNs}`;
}
return strippedNs;
},

View file

@ -65,14 +65,7 @@ export default Route.extend({
},
},
async beforeModel() {
const result = await fetch('/v1/sys/internal/ui/feature-flags', {
method: 'GET',
});
if (result.status === 200) {
const body = await result.json();
const flags = body.feature_flags || [];
this.flagsService.setFeatureFlags(flags);
}
beforeModel() {
return this.flagsService.fetchFeatureFlags();
},
});

View file

@ -58,7 +58,7 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, {
const params = this.paramsFor(this.routeName);
let namespace = params.namespaceQueryParam;
const currentTokenName = this.auth.currentTokenName;
const managedRoot = this.flagsService.managedNamespaceRoot;
const managedRoot = this.flagsService.hvdManagedNamespaceRoot;
assert(
'Cannot use VAULT_CLOUD_ADMIN_NAMESPACE flag with non-enterprise Vault version',
!(managedRoot && this.version.isCommunity)

View file

@ -5,16 +5,16 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { DEBUG } from '@glimmer/env';
import timestamp from 'core/utils/timestamp';
import { getUnixTime } from 'date-fns';
import type FlagsService from 'vault/services/flags';
import type StoreService from 'vault/services/store';
import type VersionService from 'vault/services/version';
import type { ModelFrom } from 'vault/vault/route';
import type ClientsRoute from '../clients';
import type ClientsActivityModel from 'vault/models/clients/activity';
import type ClientsConfigModel from 'vault/models/clients/config';
import type ClientsCountsController from 'vault/controllers/vault/cluster/clients/counts';
import { setStartTimeQuery } from 'core/utils/client-count-utils';
@ -25,14 +25,8 @@ export interface ClientsCountsRouteParams {
mountPath?: string | undefined;
}
interface ActivationFlagsResponse {
data: {
activated: Array<string>;
unactivated: Array<string>;
};
}
export default class ClientsCountsRoute extends Route {
@service declare readonly flags: FlagsService;
@service declare readonly store: StoreService;
@service declare readonly version: VersionService;
@ -43,6 +37,10 @@ export default class ClientsCountsRoute extends Route {
mountPath: { refreshModel: false, replace: true },
};
beforeModel() {
return this.flags.fetchActivatedFlags();
}
async getActivity(start_time: number | null, end_time: number) {
let activity, activityError;
// if there is no start_time we want the user to manually choose a date
@ -60,30 +58,6 @@ export default class ClientsCountsRoute extends Route {
return { activity, activityError };
}
async getActivatedFeatures() {
try {
const resp: ActivationFlagsResponse = await this.store
.adapterFor('application')
.ajax('/v1/sys/activation-flags', 'GET', { unauthenticated: true, namespace: null });
return resp.data?.activated;
} catch (error) {
if (DEBUG) console.error(error); // eslint-disable-line no-console
return [];
}
}
async isSecretsSyncActivated(activity: ClientsActivityModel | undefined) {
// if there are secrets, the feature is activated
if (activity && activity.total?.secret_syncs > 0) return true;
// if feature is not in license, it's definitely not activated
if (!this.version.hasSecretsSync) return false;
// otherwise check explicitly if the feature has been activated
const activatedFeatures = await this.getActivatedFeatures();
return activatedFeatures.includes('secrets-sync');
}
async model(params: ClientsCountsRouteParams) {
const { config, versionHistory } = this.modelFor('vault.cluster.clients') as ModelFrom<ClientsRoute>;
// only enterprise versions will have a relevant billing start date, if null users must select initial start time
@ -93,14 +67,11 @@ export default class ClientsCountsRoute extends Route {
const endTimestamp = Number(params.end_time) || getUnixTime(timestamp.now());
const { activity, activityError } = await this.getActivity(startTimestamp, endTimestamp);
const isSecretsSyncActivated = await this.isSecretsSyncActivated(activity);
return {
activity,
activityError,
config,
endTimestamp,
isSecretsSyncActivated,
startTimestamp,
versionHistory,
};

View file

@ -7,6 +7,7 @@ import Service, { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { keepLatestTask } from 'ember-concurrency';
import { DEBUG } from '@glimmer/env';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import type StoreService from 'vault/services/store';
import type VersionService from 'vault/services/version';
@ -24,22 +25,37 @@ export default class flagsService extends Service {
@service declare readonly version: VersionService;
@service declare readonly store: StoreService;
@tracked flags: string[] = [];
@tracked activatedFlags: string[] = [];
@tracked featureFlags: string[] = [];
setFeatureFlags(flags: string[]) {
this.flags = flags;
get isHvdManaged(): boolean {
return this.featureFlags?.includes(FLAGS.vaultCloudNamespace);
}
get managedNamespaceRoot() {
if (this.flags && this.flags.includes(FLAGS.vaultCloudNamespace)) {
return 'admin';
get hvdManagedNamespaceRoot(): string | null {
return this.isHvdManaged ? 'admin' : null;
}
getFeatureFlags = keepLatestTask(async () => {
try {
const result = await fetch('/v1/sys/internal/ui/feature-flags', {
method: 'GET',
});
if (result.status === 200) {
const body = await result.json();
this.featureFlags = body.feature_flags || [];
}
} catch (error) {
if (DEBUG) console.error(error); // eslint-disable-line no-console
}
return null;
});
fetchFeatureFlags() {
return this.getFeatureFlags.perform();
}
// TODO getter will be used in the upcoming persona service
get secretsSyncIsActivated() {
get secretsSyncIsActivated(): boolean {
return this.activatedFlags.includes('secrets-sync');
}
@ -61,4 +77,13 @@ export default class flagsService extends Service {
fetchActivatedFlags() {
return this.getActivatedFlags.perform();
}
@lazyCapabilities(apiPath`sys/activation-flags/secrets-sync/activate`) secretsSyncActivatePath;
get canActivateSecretsSync() {
return (
this.secretsSyncActivatePath.get('canCreate') !== false ||
this.secretsSyncActivatePath.get('canUpdate') !== false
);
}
}

View file

@ -48,8 +48,6 @@ export default class VersionService extends Service {
}
get hasSecretsSync() {
// TODO remove this conditional when we allow secrets sync in managed clusters
if (this.flags.managedNamespaceRoot !== null) return false;
return this.features.includes('Secrets Sync');
}

View file

@ -62,9 +62,9 @@
<div class="field-label is-normal">
<label class="is-label" for="namespace">Namespace</label>
</div>
{{#if this.managedNamespaceRoot}}
{{#if this.hvdManagedNamespaceRoot}}
<div class="field-label">
<span class="has-text-grey" data-test-managed-namespace-root>/{{this.managedNamespaceRoot}}</span>
<span class="has-text-grey" data-test-managed-namespace-root>/{{this.hvdManagedNamespaceRoot}}</span>
</div>
{{/if}}
<div class="field-body">
@ -73,7 +73,7 @@
<input
data-test-auth-form-ns-input
value={{this.namespaceInput}}
placeholder={{if this.managedNamespaceRoot "/ (Default)" "/ (Root)"}}
placeholder={{if this.hvdManagedNamespaceRoot "/ (Default)" "/ (Root)"}}
{{on "input" (perform this.updateNamespace value="target.value")}}
autocomplete="off"
spellcheck="false"

View file

@ -8,7 +8,6 @@
@activityError={{this.model.activityError}}
@config={{this.model.config}}
@endTimestamp={{this.model.endTimestamp}}
@isSecretsSyncActivated={{this.model.isSecretsSyncActivated}}
@mountPath={{this.mountPath}}
@namespace={{this.ns}}
@onFilterChange={{this.updateQueryParams}}

View file

@ -5,7 +5,6 @@
<Clients::Page::Overview
@activity={{this.model.activity}}
@isSecretsSyncActivated={{this.model.isSecretsSyncActivated}}
@versionHistory={{this.model.versionHistory}}
@startTimestamp={{this.model.startTimestamp}}
@endTimestamp={{this.model.endTimestamp}}

View file

@ -4,7 +4,6 @@
~}}
<Clients::Page::Sync
@isSecretsSyncActivated={{this.model.isSecretsSyncActivated}}
@activity={{this.model.activity}}
@versionHistory={{this.model.versionHistory}}
@startTimestamp={{this.model.startTimestamp}}

View file

@ -13,7 +13,7 @@
</p>
</div>
{{#if @subTitle}}
<p class="empty-state-subTitle" data-test-empty-state-subText>
<p class="empty-state-subTitle" data-test-empty-state-subtitle>
{{@subTitle}}
</p>
{{/if}}
@ -22,7 +22,7 @@
{{@title}}
</p>
{{#if @subTitle}}
<p class="empty-state-subTitle" data-test-empty-state-subText>
<p class="empty-state-subTitle" data-test-empty-state-subtitle>
{{@subTitle}}
</p>
{{/if}}

View file

@ -5,38 +5,27 @@
<SyncHeader @title="Secrets Sync">
<:actions>
{{! Only allow users to create a destination if secrets-sync is activated and they have the secrets sync feature }}
{{#if (and @hasSecretsSync @isActivated)}}
{{! Only allow users who have activated the feature to create a destination. }}
{{#if @isActivated}}
<Hds::Button @text="Create first destination" @route="secrets.destinations.create" data-test-cta-button />
{{/if}}
</:actions>
</SyncHeader>
<div class="box is-fullwidth is-sideless is-flex-between is-shadowless" data-test-cta-container>
{{#if @hasSecretsSync}}
<p>
Sync secrets to platforms and tools across your stack to get secrets when and where you need them.
<Hds::Link::Standalone
@icon="learn-link"
@text="Secrets Sync tutorial"
@href={{doc-link "/vault/tutorials/enterprise/secrets-sync"}}
data-test-cta-doc-link
/>
</p>
{{else}}
<p>
This premium enterprise feature allows you to sync secrets to platforms and tools across your stack to get secrets when
and where you need them.
<Hds::Link::Standalone
@text="Learn more about Secrets Sync"
@icon="docs-link"
@iconPosition="trailing"
@isHrefExternal={{true}}
@href={{doc-link "/vault/tutorials/enterprise/secrets-sync"}}
data-test-cta-doc-link
/>
</p>
{{/if}}
{{! One cta message regardless of OSS vs Enterprise with/without secrets sync or managed cluster. }}
<p>
This feature allows you to sync secrets to platforms and tools across your stack to get secrets when and where you need
them.
<Hds::Link::Standalone
@text="Learn more about Secrets Sync"
@icon="docs-link"
@iconPosition="trailing"
@isHrefExternal={{true}}
@href={{doc-link "/vault/tutorials/enterprise/secrets-sync"}}
data-test-cta-doc-link
/>
</p>
</div>
<div class="is-flex-row gap-24 has-bottom-margin-m">

View file

@ -2,28 +2,37 @@
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{#if (and this.version.hasSecretsSync (not @isActivated))}}
{{#unless this.hideOptIn}}
<Hds::Alert
@type="inline"
@color="warning"
@onDismiss={{fn (mut this.hideOptIn) true}}
data-test-secrets-sync-opt-in-banner
as |A|
>
<A.Title>Enable Secrets Sync feature</A.Title>
<A.Description>To use this feature, specific activation is required. Please review the feature documentation and enable
it. If you're upgrading from beta, your previous data will be accessible after activation.</A.Description>
<A.Button
@text="Enable"
@color="secondary"
{{on "click" (fn (mut this.showActivateSecretsSyncModal) true)}}
data-test-secrets-sync-opt-in-banner-enable
/>
</Hds::Alert>
{{/unless}}
{{/if}}
{{#unless @isActivated}}
{{#if (or @licenseHasSecretsSync @isHvdManaged)}}
{{#unless this.hideOptIn}}
{{! Allows users to dismiss activation banner if they have permissions to activate. }}
<Hds::Alert
@type="inline"
@color="warning"
@onDismiss={{if this.flags.canActivateSecretsSync (fn (mut this.hideOptIn) true) undefined}}
data-test-secrets-sync-opt-in-banner
as |A|
>
<A.Title>Enable Secrets Sync feature</A.Title>
<A.Description data-test-secrets-sync-opt-in-banner-description>To use this feature, specific activation is required.
{{if
this.flags.canActivateSecretsSync
"Please review the feature documentation and
enable it. If you're upgrading from beta, your previous data will be accessible after activation."
"Please contact your administrator to activate."
}}</A.Description>
{{#if this.flags.canActivateSecretsSync}}
<A.Button
@text="Enable"
@color="secondary"
{{on "click" (fn (mut this.showActivateSecretsSyncModal) true)}}
data-test-secrets-sync-opt-in-banner-enable
/>
{{/if}}
</Hds::Alert>
{{/unless}}
{{/if}}
{{/unless}}
{{! error message if post to activated endpoint fails }}
{{#if this.error}}
@ -185,7 +194,7 @@
</OverviewCard>
</div>
{{else}}
<Secrets::LandingCta @isActivated={{@isActivated}} @hasSecretsSync={{this.version.hasSecretsSync}} />
<Secrets::LandingCta @isActivated={{@isActivated}} />
{{/if}}
{{#if this.showActivateSecretsSyncModal}}

View file

@ -16,13 +16,16 @@ import type FlashMessageService from 'vault/services/flash-messages';
import type StoreService from 'vault/services/store';
import type RouterService from '@ember/routing/router-service';
import type VersionService from 'vault/services/version';
import type FlagsService from 'vault/services/flags';
import type { SyncDestinationAssociationMetrics } from 'vault/vault/adapters/sync/association';
import type SyncDestinationModel from 'vault/vault/models/sync/destination';
interface Args {
destinations: Array<SyncDestinationModel>;
totalVaultSecrets: number;
activatedFeatures: Array<string>;
isActivated: boolean;
licenseHasSecretsSync: boolean;
isHvdManaged: boolean;
}
export default class SyncSecretsDestinationsPageComponent extends Component<Args> {
@ -30,6 +33,7 @@ export default class SyncSecretsDestinationsPageComponent extends Component<Args
@service declare readonly store: StoreService;
@service declare readonly router: RouterService;
@service declare readonly version: VersionService;
@service declare readonly flags: FlagsService;
@tracked destinationMetrics: SyncDestinationAssociationMetrics[] = [];
@tracked page = 1;
@ -71,10 +75,13 @@ export default class SyncSecretsDestinationsPageComponent extends Component<Args
@task
@waitFor
*onFeatureConfirm() {
// must return null instead of root for non managed cluster.
// child namespaces are not sent.
const namespace = this.args.isHvdManaged ? 'admin' : null;
try {
yield this.store
.adapterFor('application')
.ajax('/v1/sys/activation-flags/secrets-sync/activate', 'POST', { namespace: null });
.ajax('/v1/sys/activation-flags/secrets-sync/activate', 'POST', { namespace });
// must refresh and not transition because transition does not refresh the model from within a namespace
yield this.router.refresh();
} catch (error) {

View file

@ -7,6 +7,7 @@ import Component from '@glimmer/component';
import { service } from '@ember/service';
import type VersionService from 'vault/services/version';
import type FlagsService from 'vault/services/flags';
import type { Breadcrumb } from 'vault/vault/app-types';
interface Args {
@ -17,12 +18,17 @@ interface Args {
export default class SyncHeaderComponent extends Component<Args> {
@service declare readonly version: VersionService;
@service declare readonly flags: FlagsService;
get badgeText() {
return this.version.hasSecretsSync
? ''
: this.version.isCommunity
? 'Enterprise feature'
: 'Premium feature';
const isHvdManaged = this.flags.isHvdManaged;
const onLicense = this.version.hasSecretsSync;
const isEnterprise = this.version.isEnterprise;
if (isHvdManaged) return 'Plus feature';
if (isEnterprise && !onLicense) return 'Premium feature';
if (!isEnterprise) return 'Enterprise feature';
// no badge for Enterprise clusters with Secrets Sync on their license--the only remaining option.
return '';
}
}

View file

@ -7,28 +7,24 @@ import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { hash } from 'rsvp';
import type RouterService from '@ember/routing/router-service';
import type FlagsService from 'vault/services/flags';
import type StoreService from 'vault/services/store';
import type VersionService from 'vault/services/version';
export default class SyncSecretsOverviewRoute extends Route {
@service declare readonly router: RouterService;
@service declare readonly store: StoreService;
@service declare readonly flags: FlagsService;
beforeModel(): void | Promise<unknown> {
if (this.flags.managedNamespaceRoot !== null) {
this.router.transitionTo('vault.cluster.dashboard');
}
}
@service declare readonly version: VersionService;
async model() {
const { activatedFeatures } = this.modelFor('secrets') as {
activatedFeatures: Array<string>;
};
const isActivated = activatedFeatures.includes('secrets-sync');
const isActivated = this.flags.secretsSyncIsActivated;
const licenseHasSecretsSync = this.version.hasSecretsSync;
const isHvdManaged = this.flags.isHvdManaged;
return hash({
licenseHasSecretsSync,
isActivated,
isHvdManaged,
destinations: isActivated ? this.store.query('sync/destination', {}).catch(() => []) : [],
associations: isActivated
? this.store

View file

@ -7,4 +7,6 @@
@destinations={{this.model.destinations}}
@totalVaultSecrets={{this.model.associations.total_secrets}}
@isActivated={{this.model.isActivated}}
@licenseHasSecretsSync={{this.model.licenseHasSecretsSync}}
@isHvdManaged={{this.model.isHvdManaged}}
/>

View file

@ -7,6 +7,7 @@ import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import clientsHandler, { STATIC_NOW, LICENSE_START, UPGRADE_DATE } from 'vault/mirage/handlers/clients';
import syncHandler from 'vault/mirage/handlers/sync';
import sinon from 'sinon';
import { visit, click, findAll, settled } from '@ember/test-helpers';
import authPage from 'vault/tests/pages/auth';
@ -33,12 +34,6 @@ module('Acceptance | clients | overview', function (hooks) {
return visit('/vault/clients/counts/overview');
});
test('it should render the correct tabs', async function (assert) {
assert.dom(GENERAL.tab('overview')).exists();
assert.dom(GENERAL.tab('token')).exists();
assert.dom(GENERAL.tab('acme')).exists();
});
test('it should render charts', async function (assert) {
assert
.dom(CLIENT_COUNT.counts.startMonth)
@ -59,9 +54,11 @@ module('Acceptance | clients | overview', function (hooks) {
test('it should update charts when querying date ranges', async function (assert) {
// query for single, historical month with no new counts (July 2023)
await click(CLIENT_COUNT.rangeDropdown);
await click('[data-test-show-calendar]');
await click('[data-test-previous-year]');
await click(`[data-test-calendar-month=${ARRAY_OF_MONTHS[LICENSE_START.getMonth()]}]`);
await click(CLIENT_COUNT.calendarWidget.customEndMonth);
await click(CLIENT_COUNT.calendarWidget.previousYear);
const month = ARRAY_OF_MONTHS[LICENSE_START.getMonth()];
await click(CLIENT_COUNT.calendarWidget.calendarMonth(month));
assert
.dom(CLIENT_COUNT.usageStats('Vault client counts'))
.doesNotExist('running total single month stat boxes do not show');
@ -70,24 +67,24 @@ module('Acceptance | clients | overview', function (hooks) {
.doesNotExist('running total month over month charts do not show');
assert.dom(CLIENT_COUNT.attributionBlock).exists('attribution area shows');
assert
.dom('[data-test-chart-container="new-clients"] [data-test-component="empty-state"]')
.dom(`${CHARTS.container('new-clients')} ${GENERAL.emptyStateTitle}`)
.exists('new client attribution has empty state');
assert
.dom('[data-test-empty-state-subtext]')
.dom(GENERAL.emptyStateSubtitle)
.hasText('There are no new clients for this namespace during this time period. ');
assert.dom('[data-test-chart-container="total-clients"]').exists('total client attribution chart shows');
assert.dom(CHARTS.container('total-clients')).exists('total client attribution chart shows');
// reset to billing period
await click(CLIENT_COUNT.rangeDropdown);
await click('[data-test-current-billing-period]');
await click(CLIENT_COUNT.currentBillingPeriod);
// change billing start to month/year of upgrade to 1.10
await click(CLIENT_COUNT.counts.startEdit);
await click(CLIENT_COUNT.monthDropdown);
await click(`[data-test-dropdown-month="${ARRAY_OF_MONTHS[UPGRADE_DATE.getMonth()]}"]`);
await click(CLIENT_COUNT.dateDropdown.selectMonth(ARRAY_OF_MONTHS[UPGRADE_DATE.getMonth()]));
await click(CLIENT_COUNT.yearDropdown);
await click(`[data-test-dropdown-year="${UPGRADE_DATE.getFullYear()}"]`);
await click('[data-test-date-dropdown-submit]');
await click(CLIENT_COUNT.dateDropdown.selectYear(UPGRADE_DATE.getFullYear()));
await click(CLIENT_COUNT.dateDropdown.submit);
assert.dom(CLIENT_COUNT.attributionBlock).exists('Shows attribution area');
assert
.dom(CHARTS.container('Vault client counts'))
@ -99,10 +96,10 @@ module('Acceptance | clients | overview', function (hooks) {
// query for single, historical month (upgrade month)
await click(CLIENT_COUNT.rangeDropdown);
await click('[data-test-show-calendar]');
assert.dom('[data-test-display-year]').hasText('2024');
await click('[data-test-previous-year]');
await click('[data-test-calendar-month="September"]');
await click(CLIENT_COUNT.calendarWidget.customEndMonth);
assert.dom(CLIENT_COUNT.calendarWidget.displayYear).hasText('2024');
await click(CLIENT_COUNT.calendarWidget.previousYear);
await click(CLIENT_COUNT.calendarWidget.calendarMonth('September'));
assert
.dom(CLIENT_COUNT.usageStats('Vault client counts'))
.exists('running total single month usage stats show');
@ -115,8 +112,8 @@ module('Acceptance | clients | overview', function (hooks) {
// query historical date range (from September 2023 to December 2023)
await click(CLIENT_COUNT.rangeDropdown);
await click('[data-test-show-calendar]');
await click('[data-test-calendar-month="December"]');
await click(CLIENT_COUNT.calendarWidget.customEndMonth);
await click(CLIENT_COUNT.calendarWidget.calendarMonth('December'));
assert.dom(CLIENT_COUNT.attributionBlock).exists('Shows attribution area');
assert
@ -130,14 +127,14 @@ module('Acceptance | clients | overview', function (hooks) {
// reset to billing period
await click(CLIENT_COUNT.rangeDropdown);
await click('[data-test-current-billing-period]');
await click(CLIENT_COUNT.currentBillingPeriod);
// query month older than count start date
await click(CLIENT_COUNT.counts.startEdit);
await click(CLIENT_COUNT.monthDropdown);
await click(`[data-test-dropdown-month="${ARRAY_OF_MONTHS[LICENSE_START.getMonth()]}"]`);
await click(CLIENT_COUNT.dateDropdown.selectMonth(ARRAY_OF_MONTHS[LICENSE_START.getMonth()]));
await click(CLIENT_COUNT.yearDropdown);
await click(`[data-test-dropdown-year="${LICENSE_START.getFullYear() - 3}"]`);
await click('[data-test-date-dropdown-submit]');
await click(CLIENT_COUNT.dateDropdown.selectYear(LICENSE_START.getFullYear() - 3));
await click(CLIENT_COUNT.dateDropdown.submit);
assert
.dom(CLIENT_COUNT.counts.startDiscrepancy)
.hasTextContaining(
@ -147,6 +144,9 @@ module('Acceptance | clients | overview', function (hooks) {
});
test('totals filter correctly with full data', async function (assert) {
// stub secrets sync being activated
this.owner.lookup('service:flags').activatedFlags = ['secrets-sync'];
assert
.dom(CHARTS.container('Vault client counts'))
.exists('Shows running totals with monthly breakdown charts');
@ -228,15 +228,8 @@ module('Acceptance | clients | overview | sync in license, activated', function
hooks.beforeEach(async function () {
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
clientsHandler(this.server);
this.store = this.owner.lookup('service:store');
// add feature to license
this.server.get('/sys/license/features', () => ({ features: ['Secrets Sync'] }));
// activate feature
this.server.get('/sys/activation-flags', () => ({
data: { activated: ['secrets-sync'], unactivated: [] },
}));
syncHandler(this.server);
await authPage.login();
return visit('/vault/clients/counts/overview');
@ -246,12 +239,16 @@ module('Acceptance | clients | overview | sync in license, activated', function
assert.dom(GENERAL.tab('sync')).exists();
});
test('it should show secrets sync data in overview and tab', async function (assert) {
test('it should show secrets sync stats', async function (assert) {
assert.dom(CLIENT_COUNT.statTextValue('Secret sync')).exists('shows secret sync data on overview');
});
test('it should navigate to secrets sync page', async function (assert) {
await click(GENERAL.tab('sync'));
assert.dom(GENERAL.tab('sync')).hasClass('active');
assert.dom(GENERAL.emptyStateTitle).doesNotExist();
assert
.dom(CHARTS.chart('Secrets sync usage'))
.exists('chart is shown because feature is active and has data');
@ -263,7 +260,6 @@ module('Acceptance | clients | overview | sync in license, not activated', funct
setupMirage(hooks);
hooks.beforeEach(async function () {
this.store = this.owner.lookup('service:store');
this.server.get('/sys/license/features', () => ({ features: ['Secrets Sync'] }));
await authPage.login();
@ -274,12 +270,11 @@ module('Acceptance | clients | overview | sync in license, not activated', funct
assert.dom(GENERAL.tab('sync')).exists('sync tab is shown because feature is in license');
});
test('it should hide secrets sync charts', async function (assert) {
test('it should hide secrets sync stats', async function (assert) {
assert
.dom(CHARTS.chart('Secrets sync usage'))
.doesNotExist('chart is hidden because feature is not activated');
assert.dom('[data-test-stat-text="secret-syncs"]').doesNotExist();
.dom(CLIENT_COUNT.statTextValue('Secret sync'))
.doesNotExist('stat is hidden because feature is not activated');
assert.dom(CLIENT_COUNT.statTextValue('Entity')).exists('other stats are still visible');
});
});
@ -288,7 +283,6 @@ module('Acceptance | clients | overview | sync not in license', function (hooks)
setupMirage(hooks);
hooks.beforeEach(async function () {
this.store = this.owner.lookup('service:store');
// mocks endpoint for no additional license modules
this.server.get('/sys/license/features', () => ({ features: [] }));
@ -300,9 +294,30 @@ module('Acceptance | clients | overview | sync not in license', function (hooks)
assert.dom(GENERAL.tab('sync')).doesNotExist();
});
test('it should hide secrets sync charts', async function (assert) {
assert.dom(CHARTS.chart('Secrets sync usage')).doesNotExist();
assert.dom('[data-test-stat-text="secret-syncs"]').doesNotExist();
test('it should hide secrets sync stats', async function (assert) {
assert.dom(CLIENT_COUNT.statTextValue('Secret sync')).doesNotExist();
assert.dom(CLIENT_COUNT.statTextValue('Entity')).exists('other stats are still visible');
});
});
module('Acceptance | clients | overview | HVD', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
syncHandler(this.server);
this.owner.lookup('service:flags').featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
await authPage.login();
return visit('/vault/clients/counts/overview');
});
test('it should show the secrets sync tab', async function (assert) {
assert.dom(GENERAL.tab('sync')).exists();
});
test('it should show secrets sync stats', async function (assert) {
assert.dom(CLIENT_COUNT.statTextValue('Secret sync')).exists();
});
});

View file

@ -15,71 +15,57 @@ import authPage from 'vault/tests/pages/auth';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { CLIENT_COUNT, CHARTS } from 'vault/tests/helpers/clients/client-count-selectors';
module('Acceptance | clients | sync | activated', function (hooks) {
module('Acceptance | clients | sync', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
syncHandler(this.server);
await authPage.login();
return visit('/vault/clients/counts/sync');
});
test('it should render charts when secrets sync is activated', async function (assert) {
syncHandler(this.server);
assert.dom(CHARTS.chart('Secrets sync usage')).exists('Secrets sync usage chart is rendered');
assert.dom(CLIENT_COUNT.statText('Total sync clients')).exists('Total sync clients chart is rendered');
assert.dom(GENERAL.emptyStateTitle).doesNotExist();
});
});
module('Acceptance | clients | sync | not activated', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
this.server.get('/sys/internal/counters/config', function () {
return CONFIG_RESPONSE;
module('sync activated', function (hooks) {
hooks.beforeEach(async function () {
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
syncHandler(this.server);
await authPage.login();
return visit('/vault/clients/counts/sync');
});
test('it should render charts when secrets sync is activated', async function (assert) {
syncHandler(this.server);
assert.dom(CHARTS.chart('Secrets sync usage')).exists('Secrets sync usage chart is rendered');
assert.dom(CLIENT_COUNT.statText('Total sync clients')).exists('Total sync clients chart is rendered');
assert.dom(GENERAL.emptyStateTitle).doesNotExist();
});
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
await authPage.login();
return visit('/vault/clients/counts/sync');
});
test('it should show an empty state when secrets sync is not activated', async function (assert) {
assert.expect(3);
module('sync not activated', function (hooks) {
hooks.beforeEach(async function () {
this.server.get('/sys/internal/counters/config', function () {
return CONFIG_RESPONSE;
});
sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW));
await authPage.login();
return visit('/vault/clients/counts/sync');
});
// ensure secret_syncs clients activity is 0
this.server.get('/sys/internal/counters/activity', () => {
// return only the things that determine whether to show/hide secrets sync
return {
data: {
total: {
secret_syncs: 0,
test('it should show an empty state when secrets sync is not activated', async function (assert) {
assert.expect(3);
this.server.get('/sys/activation-flags', () => {
assert.true(true, '/sys/activation-flags/ is called to check if secrets-sync is activated');
return {
data: {
activated: [],
unactivated: ['secrets-sync'],
},
},
};
};
});
assert.dom(GENERAL.emptyStateTitle).exists('Shows empty state when secrets-sync is not activated');
await click(`${GENERAL.emptyStateActions} .hds-link-standalone`);
assert.strictEqual(
currentURL(),
'/vault/sync/secrets/overview',
'action button navigates to secrets sync overview page'
);
});
this.server.get('/sys/activation-flags', () => {
assert.true(true, '/sys/activation-flags/ is called to check if secrets-sync is activated');
return {
data: {
activated: [],
unactivated: ['secrets-sync'],
},
};
});
assert.dom(GENERAL.emptyStateTitle).exists('Shows empty state when secrets-sync is not activated');
await click(`${GENERAL.emptyStateActions} .hds-link-standalone`);
assert.strictEqual(
currentURL(),
'/vault/sync/secrets/overview',
'action button navigates to secrets sync overview page'
);
});
});

View file

@ -9,10 +9,6 @@ import { setupApplicationTest } from 'ember-qunit';
import Pretender from 'pretender';
import { getManagedNamespace } from 'vault/routes/vault/cluster';
const FEATURE_FLAGS_RESPONSE = {
feature_flags: ['VAULT_CLOUD_ADMIN_NAMESPACE'],
};
module('Acceptance | Enterprise | Managed namespace root', function (hooks) {
setupApplicationTest(hooks);
@ -22,10 +18,9 @@ module('Acceptance | Enterprise | Managed namespace root', function (hooks) {
* we have to populate them on the beforeEach hook because
* the fetch won't trigger again within the tests
*/
this.owner.lookup('service:flags').featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
this.server = new Pretender(function () {
this.get('/v1/sys/internal/ui/feature-flags', () => {
return [200, { 'Content-Type': 'application/json' }, JSON.stringify(FEATURE_FLAGS_RESPONSE)];
});
this.get('/v1/sys/health', this.passthrough);
this.get('/v1/sys/seal-status', this.passthrough);
this.get('/v1/sys/license/features', this.passthrough);

View file

@ -8,6 +8,7 @@ import { setupApplicationTest } from 'ember-qunit';
import { click, currentRouteName, fillIn, visit, waitUntil, find, waitFor } from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import mfaLoginHandler, { validationHandler } from '../../mirage/handlers/mfa-login';
import { GENERAL } from '../helpers/general-selectors';
module('Acceptance | mfa-login', function (hooks) {
setupApplicationTest(hooks);
@ -181,7 +182,7 @@ module('Acceptance | mfa-login', function (hooks) {
assert.dom('[data-test-auth-form]').doesNotExist('Auth form hidden when mfa fails');
assert.dom('[data-test-empty-state-title]').hasText('Unauthorized', 'Error title renders');
assert
.dom('[data-test-empty-state-subText]')
.dom(GENERAL.emptyStateSubtitle)
.hasText('PingId MFA validation failed', 'Error message from server renders');
assert
.dom('[data-test-empty-state-message]')

View file

@ -163,7 +163,7 @@ module('Acceptance | sync | overview', function (hooks) {
assert.strictEqual(
req.requestHeaders['X-Vault-Namespace'],
undefined,
'Request is made to root namespace'
'Request is made to undefined namespace'
);
return {};
});
@ -177,10 +177,10 @@ module('Acceptance | sync | overview', function (hooks) {
await click(ts.overview.optInConfirm);
});
test.skip('it should make activation-flag requests to correct namespace when managed', async function (assert) {
// TODO: unskip for 1.16.1 when managed is supported
assert.expect(3);
this.owner.lookup('service:flags').setFeatureFlags(['VAULT_CLOUD_ADMIN_NAMESPACE']);
test('it should make activation-flag requests to correct namespace when managed', async function (assert) {
assert.expect(4);
// should call GET activation-flags twice because we need an updated response after activating the feature
this.owner.lookup('service:flags').featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
this.server.get('/sys/activation-flags', (_, req) => {
assert.deepEqual(req.requestHeaders, {}, 'Request is unauthenticated and in root namespace');
@ -195,7 +195,7 @@ module('Acceptance | sync | overview', function (hooks) {
assert.strictEqual(
req.requestHeaders['X-Vault-Namespace'],
'admin',
'Request is made to admin namespace'
'Request is made to the admin namespace'
);
return {};
});

View file

@ -41,6 +41,7 @@ export const CLIENT_COUNT = {
customEndMonth: '[data-test-show-calendar]',
previousYear: '[data-test-previous-year]',
nextYear: '[data-test-next-year]',
displayYear: '[data-test-display-year]',
calendarMonth: (month: string) => `[data-test-calendar-month="${month}"]`,
},
selectedAuthMount: 'div#mounts-search-select [data-test-selected-option] div',

View file

@ -24,6 +24,7 @@ export const GENERAL = {
confirmButton: '[data-test-confirm-button]',
confirmTrigger: '[data-test-confirm-action-trigger]',
emptyStateTitle: '[data-test-empty-state-title]',
emptyStateSubtitle: '[data-test-empty-state-subtitle]',
emptyStateMessage: '[data-test-empty-state-message]',
emptyStateActions: '[data-test-empty-state-actions]',
menuTrigger: '[data-test-popup-menu-trigger]',

View file

@ -53,6 +53,8 @@ export const PAGE = {
overview: {
optInBanner: '[data-test-secrets-sync-opt-in-banner]',
optInBannerEnable: '[data-test-secrets-sync-opt-in-banner-enable]',
optInBannerDescription: '[data-test-secrets-sync-opt-in-banner-description]',
optInDismiss: '[data-test-secrets-sync-opt-in-banner] [data-test-icon="x"]',
optInModal: '[data-test-secrets-sync-opt-in-modal]',
optInCheck: '[data-test-opt-in-check]',
optInConfirm: '[data-test-opt-in-confirm]',
@ -70,7 +72,7 @@ export const PAGE = {
action: (name) => `[data-test-overview-table-action="${name}"]`,
},
},
syncBadge: {
badgeText: {
icon: (name) => `[data-test-icon="${name}"]`,
text: '.hds-badge__text',
},

View file

@ -0,0 +1,44 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
module('Integration | Component | clients/counts/nav-bar', function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.showSecretsSync = false;
this.renderComponent = async () => {
await render(hbs`<Clients::Counts::NavBar @showSecretsSync={{this.showSecretsSync}} />`);
};
});
test('it renders default tabs', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.tab('overview')).hasText('Overview');
assert.dom(GENERAL.tab('token')).hasText('Entity/Non-entity clients');
assert.dom(GENERAL.tab('acme')).hasText('ACME clients');
});
test('it shows secrets sync tab if showSecretsSync is true', async function (assert) {
this.showSecretsSync = true;
await this.renderComponent();
assert.dom(GENERAL.tab('sync')).exists();
});
test('it should not show secrets sync tab if showSecretsSync is false', async function (assert) {
this.showSecretsSync = false;
await this.renderComponent();
assert.dom(GENERAL.tab('sync')).doesNotExist();
});
});

View file

@ -10,6 +10,7 @@ import { find, render, findAll } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { format, formatRFC3339, subMonths } from 'date-fns';
import timestamp from 'core/utils/timestamp';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
module('Integration | Component | clients/line-chart', function (hooks) {
setupRenderingTest(hooks);
@ -270,7 +271,7 @@ module('Integration | Component | clients/line-chart', function (hooks) {
assert.dom('[data-test-component="empty-state"]').exists('renders empty state when no data');
assert
.dom('[data-test-empty-state-subtext]')
.dom(GENERAL.emptyStateSubtitle)
.hasText(
`this is a custom message to explain why you're not seeing a line chart`,
'custom message renders'

View file

@ -8,7 +8,8 @@ import { setupRenderingTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { render, findAll } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import clientsHandler, { LICENSE_START, STATIC_NOW } from 'vault/mirage/handlers/clients';
import { LICENSE_START, STATIC_NOW } from 'vault/mirage/handlers/clients';
import syncHandler from 'vault/mirage/handlers/sync';
import { getUnixTime } from 'date-fns';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { CLIENT_COUNT, CHARTS } from 'vault/tests/helpers/clients/client-count-selectors';
@ -26,22 +27,9 @@ module('Integration | Component | clients | Clients::Page::Sync', function (hook
setupMirage(hooks);
hooks.beforeEach(async function () {
clientsHandler(this.server);
this.store = this.owner.lookup('service:store');
const activityQuery = {
start_time: { timestamp: START_TIME },
end_time: { timestamp: END_TIME },
};
// set this to 0
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
this.startTimestamp = START_TIME;
this.endTimestamp = END_TIME;
this.isSecretsSyncActivated = true;
this.renderComponent = () =>
render(hbs`
<Clients::Page::Sync
@isSecretsSyncActivated={{this.isSecretsSyncActivated}}
@activity={{this.activity}}
@versionHistory={{this.versionHistory}}
@startTimestamp={{this.startTimestamp}}
@ -52,172 +40,189 @@ module('Integration | Component | clients | Clients::Page::Sync', function (hook
`);
});
test('it should render with full month activity data', async function (assert) {
const monthCount = this.activity.byMonth.length;
assert.expect(8 + monthCount * 2);
const expectedTotal = formatNumber([this.activity.total.secret_syncs]);
const expectedAvg = formatNumber([calculateAverage(this.activity.byMonth, 'secret_syncs')]);
const expectedNewAvg = formatNumber([
calculateAverage(
this.activity.byMonth.map((m) => m?.new_clients),
'secret_syncs'
),
]);
await this.renderComponent();
assert
.dom(statText('Total sync clients'))
.hasText(
`Total sync clients The total number of secrets synced from Vault to other destinations during this date range. ${expectedTotal}`,
`renders correct total sync stat ${expectedTotal}`
);
assert
.dom(statText('Average sync clients per month'))
.hasText(
`Average sync clients per month ${expectedAvg}`,
`renders correct average sync stat ${expectedAvg}`
);
assert.dom(statText('Average new sync clients per month')).hasTextContaining(`${expectedNewAvg}`);
module('with secrets sync not activated', function () {
test('it should render an empty state', async function (assert) {
await this.renderComponent();
const formattedTimestamp = dateFormat([this.activity.responseTimestamp, 'MMM d yyyy, h:mm:ss aaa'], {
withTimeZone: true,
});
assert.dom(CHARTS.timestamp).hasText(`Updated ${formattedTimestamp}`, 'renders response timestamp');
assertBarChart(assert, 'Secrets sync usage', this.activity.byMonth);
assertBarChart(assert, 'Monthly new', this.activity.byMonth);
});
test('it should render stats without chart for a single month', async function (assert) {
assert.expect(5);
const activityQuery = { start_time: { timestamp: END_TIME }, end_time: { timestamp: END_TIME } };
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
const expectedTotal = formatNumber([this.activity.total.secret_syncs]);
await this.renderComponent();
assert.dom(CHARTS.chart('Secrets sync usage')).doesNotExist('total usage chart does not render');
assert.dom(CHARTS.container('Monthly new')).doesNotExist('monthly new chart does not render');
assert.dom(statText('Average sync clients per month')).doesNotExist();
assert.dom(statText('Average new sync clients per month')).doesNotExist();
assert
.dom(usageStats('Secrets sync usage'))
.hasText(
`Secrets sync usage Usage metrics tutorial This data can be used to understand how many secrets sync clients have been used for this date range. Each Vault secret that is synced to at least one destination counts as one Vault client. Total sync clients ${expectedTotal}`,
'it renders usage stats with single month copy'
);
});
// EMPTY STATES
test('it should render empty state when sync data does not exist for a date range', async function (assert) {
assert.expect(8);
// this happens when a user queries historical data that predates the monthly breakdown (added in 1.11)
// only entity + non-entity clients existed then, so we show an empty state for sync clients
// because the activity response just returns { secret_syncs: 0 } which isn't very clear
this.activity.byMonth = [];
await this.renderComponent();
assert.dom(GENERAL.emptyStateTitle).hasText('No secrets sync clients');
assert.dom(GENERAL.emptyStateMessage).hasText('There is no sync data available for this date range.');
assert.dom(CHARTS.chart('Secrets sync usage')).doesNotExist('vertical bar chart does not render');
assert.dom(CHARTS.container('Monthly new')).doesNotExist('monthly new chart does not render');
assert.dom(statText('Total sync clients')).doesNotExist();
assert.dom(statText('Average sync clients per month')).doesNotExist();
assert.dom(statText('Average new sync clients per month')).doesNotExist();
assert.dom(usageStats('Secrets sync usage')).doesNotExist();
});
test('it should render empty state when sync data does not exist for a single month', async function (assert) {
assert.expect(1);
const activityQuery = { start_time: { timestamp: START_TIME }, end_time: { timestamp: START_TIME } };
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
this.activity.byMonth = [];
await this.renderComponent();
assert.dom(GENERAL.emptyStateMessage).hasText('There is no sync data available for this month.');
});
test('it should render an empty state if secrets sync is not activated', async function (assert) {
this.isSecretsSyncActivated = false;
await this.renderComponent();
assert.dom(GENERAL.emptyStateTitle).hasText('No Secrets Sync clients');
assert
.dom(GENERAL.emptyStateMessage)
.hasText('No data is available because Secrets Sync has not been activated.');
assert.dom(GENERAL.emptyStateActions).hasText('Activate Secrets Sync');
assert.dom(CHARTS.chart('Secrets sync usage')).doesNotExist();
assert.dom(statText('Total sync clients')).doesNotExist();
assert.dom(statText('Average sync clients per month')).doesNotExist();
});
test('it should render an empty total usage chart if secrets sync is activated but monthly syncs are null or 0', async function (assert) {
this.isSecretsSyncActivated = true;
// manually stub because mirage isn't setup to handle mixed data yet
const counts = {
clients: 10,
entity_clients: 4,
non_entity_clients: 6,
secret_syncs: 0,
};
const monthData = {
month: '1/24',
timestamp: '2024-01-01T00:00:00-08:00',
...counts,
namespaces: [
{
label: 'root',
...counts,
mounts: [],
},
],
};
this.activity.byMonth = [
{
...monthData,
namespaces_by_key: {
root: {
...monthData,
mounts_by_key: {},
},
},
new_clients: {
...monthData,
},
},
];
this.activity.total = counts;
const monthCount = this.activity.byMonth.length;
assert.expect(6 + monthCount * 2);
await this.renderComponent();
assert.dom(CHARTS.chart('Secrets sync usage')).exists('renders empty sync usage chart');
assert
.dom(statText('Total sync clients'))
.hasText(
'Total sync clients The total number of secrets synced from Vault to other destinations during this date range. 0'
);
assert
.dom(statText('Average sync clients per month'))
.doesNotExist('Does not render average if the calculation is 0');
findAll(`${CHARTS.chart('Secrets sync usage')} ${CHARTS.xAxisLabel}`).forEach((e, i) => {
assert.dom(GENERAL.emptyStateTitle).hasText('No Secrets Sync clients');
assert
.dom(e)
.dom(GENERAL.emptyStateMessage)
.hasText('No data is available because Secrets Sync has not been activated.');
assert.dom(GENERAL.emptyStateActions).hasText('Activate Secrets Sync');
assert.dom(CHARTS.chart('Secrets sync usage')).doesNotExist();
assert.dom(statText('Total sync clients')).doesNotExist();
assert.dom(statText('Average sync clients per month')).doesNotExist();
});
});
module('with secrets sync activated', function (hooks) {
hooks.beforeEach(async function () {
syncHandler(this.server);
this.owner.lookup('service:flags').activatedFlags = ['secrets-sync'];
this.store = this.owner.lookup('service:store');
const activityQuery = {
start_time: { timestamp: START_TIME },
end_time: { timestamp: END_TIME },
};
// set this to 0
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
this.startTimestamp = START_TIME;
this.endTimestamp = END_TIME;
});
test('it should render with full month activity data', async function (assert) {
const monthCount = this.activity.byMonth.length;
assert.expect(8 + monthCount * 2);
const expectedTotal = formatNumber([this.activity.total.secret_syncs]);
const expectedAvg = formatNumber([calculateAverage(this.activity.byMonth, 'secret_syncs')]);
const expectedNewAvg = formatNumber([
calculateAverage(
this.activity.byMonth.map((m) => m?.new_clients),
'secret_syncs'
),
]);
await this.renderComponent();
assert
.dom(statText('Total sync clients'))
.hasText(
`${this.activity.byMonth[i].month}`,
`renders x-axis labels for empty bar chart: ${this.activity.byMonth[i].month}`
`Total sync clients The total number of secrets synced from Vault to other destinations during this date range. ${expectedTotal}`,
`renders correct total sync stat ${expectedTotal}`
);
assert
.dom(statText('Average sync clients per month'))
.hasText(
`Average sync clients per month ${expectedAvg}`,
`renders correct average sync stat ${expectedAvg}`
);
assert.dom(statText('Average new sync clients per month')).hasTextContaining(`${expectedNewAvg}`);
const formattedTimestamp = dateFormat([this.activity.responseTimestamp, 'MMM d yyyy, h:mm:ss aaa'], {
withTimeZone: true,
});
assert.dom(CHARTS.timestamp).hasText(`Updated ${formattedTimestamp}`, 'renders response timestamp');
assertBarChart(assert, 'Secrets sync usage', this.activity.byMonth);
assertBarChart(assert, 'Monthly new', this.activity.byMonth);
});
test('it should render stats without chart for a single month', async function (assert) {
assert.expect(5);
const activityQuery = { start_time: { timestamp: END_TIME }, end_time: { timestamp: END_TIME } };
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
const expectedTotal = formatNumber([this.activity.total.secret_syncs]);
await this.renderComponent();
assert.dom(CHARTS.chart('Secrets sync usage')).doesNotExist('total usage chart does not render');
assert.dom(CHARTS.container('Monthly new')).doesNotExist('monthly new chart does not render');
assert.dom(statText('Average sync clients per month')).doesNotExist();
assert.dom(statText('Average new sync clients per month')).doesNotExist();
assert
.dom(usageStats('Secrets sync usage'))
.hasText(
`Secrets sync usage Usage metrics tutorial This data can be used to understand how many secrets sync clients have been used for this date range. Each Vault secret that is synced to at least one destination counts as one Vault client. Total sync clients ${expectedTotal}`,
'it renders usage stats with single month copy'
);
});
findAll(`${CHARTS.chart('Secrets sync usage')} ${CHARTS.verticalBar}`).forEach((e, i) => {
assert.dom(e).isNotVisible(`does not render data bar for: ${this.activity.byMonth[i].month}`);
// EMPTY STATES
test('it should render empty state when sync data does not exist for a date range', async function (assert) {
assert.expect(8);
// this happens when a user queries historical data that predates the monthly breakdown (added in 1.11)
// only entity + non-entity clients existed then, so we show an empty state for sync clients
// because the activity response just returns { secret_syncs: 0 } which isn't very clear
this.activity.byMonth = [];
await this.renderComponent();
assert.dom(GENERAL.emptyStateTitle).hasText('No secrets sync clients');
assert.dom(GENERAL.emptyStateMessage).hasText('There is no sync data available for this date range.');
assert.dom(CHARTS.chart('Secrets sync usage')).doesNotExist('vertical bar chart does not render');
assert.dom(CHARTS.container('Monthly new')).doesNotExist('monthly new chart does not render');
assert.dom(statText('Total sync clients')).doesNotExist();
assert.dom(statText('Average sync clients per month')).doesNotExist();
assert.dom(statText('Average new sync clients per month')).doesNotExist();
assert.dom(usageStats('Secrets sync usage')).doesNotExist();
});
assert
.dom(CHARTS.container('Monthly new'))
.doesNotExist('empty monthly new chart does not render at all');
assert.dom(statText('Average sync clients per month')).doesNotExist();
assert.dom(statText('Average new sync clients per month')).doesNotExist();
test('it should render empty state when sync data does not exist for a single month', async function (assert) {
assert.expect(1);
const activityQuery = { start_time: { timestamp: START_TIME }, end_time: { timestamp: START_TIME } };
this.activity = await this.store.queryRecord('clients/activity', activityQuery);
this.activity.byMonth = [];
await this.renderComponent();
assert.dom(GENERAL.emptyStateMessage).hasText('There is no sync data available for this month.');
});
test('it should render an empty total usage chart if secrets sync is activated but monthly syncs are null or 0', async function (assert) {
// manually stub because mirage isn't setup to handle mixed data yet
const counts = {
clients: 10,
entity_clients: 4,
non_entity_clients: 6,
secret_syncs: 0,
};
const monthData = {
month: '1/24',
timestamp: '2024-01-01T00:00:00-08:00',
...counts,
namespaces: [
{
label: 'root',
...counts,
mounts: [],
},
],
};
this.activity.byMonth = [
{
...monthData,
namespaces_by_key: {
root: {
...monthData,
mounts_by_key: {},
},
},
new_clients: {
...monthData,
},
},
];
this.activity.total = counts;
const monthCount = this.activity.byMonth.length;
assert.expect(6 + monthCount * 2);
await this.renderComponent();
assert.dom(CHARTS.chart('Secrets sync usage')).exists('renders empty sync usage chart');
assert
.dom(statText('Total sync clients'))
.hasText(
'Total sync clients The total number of secrets synced from Vault to other destinations during this date range. 0'
);
assert
.dom(statText('Average sync clients per month'))
.doesNotExist('Does not render average if the calculation is 0');
findAll(`${CHARTS.chart('Secrets sync usage')} ${CHARTS.xAxisLabel}`).forEach((e, i) => {
assert
.dom(e)
.hasText(
`${this.activity.byMonth[i].month}`,
`renders x-axis labels for empty bar chart: ${this.activity.byMonth[i].month}`
);
});
findAll(`${CHARTS.chart('Secrets sync usage')} ${CHARTS.verticalBar}`).forEach((e, i) => {
assert.dom(e).isNotVisible(`does not render data bar for: ${this.activity.byMonth[i].month}`);
});
assert
.dom(CHARTS.container('Monthly new'))
.doesNotExist('empty monthly new chart does not render at all');
assert.dom(statText('Average sync clients per month')).doesNotExist();
assert.dom(statText('Average new sync clients per month')).doesNotExist();
});
});
});

View file

@ -49,6 +49,7 @@ module('Integration | Component | sidebar-nav-cluster', function (hooks) {
test('it should hide links and headings user does not have access too', async function (assert) {
await renderComponent();
assert
.dom('[data-test-sidebar-nav-link]')
.exists({ count: 3 }, 'Nav links are hidden other than secrets, secrets sync and dashboard');
@ -85,7 +86,8 @@ module('Integration | Component | sidebar-nav-cluster', function (hooks) {
test('it should render badge for promotional links on community version', async function (assert) {
const promotionalLinks = ['Secrets Sync'];
stubFeaturesAndPermissions(this.owner, false, true);
// if no features passed, it defaults to all features and we need to specifically remove Secrets Sync
stubFeaturesAndPermissions(this.owner, false, true, []);
await renderComponent();
promotionalLinks.forEach((link) => {
@ -135,13 +137,16 @@ module('Integration | Component | sidebar-nav-cluster', function (hooks) {
});
});
test('it should not show sync links for managed cluster', async function (assert) {
this.owner.lookup('service:flags').setFeatureFlags(['VAULT_CLOUD_ADMIN_NAMESPACE']);
stubFeaturesAndPermissions(this.owner, true, true, ['Secrets Sync']);
test('it should render badge for promotional links on managed clusters', async function (assert) {
this.owner.lookup('service:flags').featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
const promotionalLinks = ['Secrets Sync'];
stubFeaturesAndPermissions(this.owner, true, true);
await renderComponent();
assert
.dom(`[data-test-sidebar-nav-link="Secrets Sync"]`)
.doesNotExist(`Secret Sync is hidden in managed vault`);
promotionalLinks.forEach((link) => {
assert
.dom(`[data-test-sidebar-nav-link="${link}"]`)
.hasText(`${link} Plus`, `${link} link renders Plus badge`);
});
});
});

View file

@ -35,8 +35,8 @@ module('Integration | Component | SyncStatusBadge', function (hooks) {
test('it should render when status does not exist', async function (assert) {
assert.expect(2);
await this.renderComponent();
assert.dom(PAGE.syncBadge.icon('help')).exists('renders help icon');
assert.dom(PAGE.syncBadge.text).hasText(this.status);
assert.dom(PAGE.badgeText.icon('help')).exists('renders help icon');
assert.dom(PAGE.badgeText.text).hasText(this.status);
});
test('it renders badge and icon for each status type', async function (assert) {
@ -46,8 +46,8 @@ module('Integration | Component | SyncStatusBadge', function (hooks) {
const label = toLabel([status]);
const { icon, color } = SYNC_STATUSES[status];
await this.renderComponent();
assert.dom(PAGE.syncBadge.icon(icon)).exists(`status: ${status} renders icon: ${icon}`);
assert.dom(PAGE.syncBadge.text).hasText(label, `status: ${status} renders label: ${label}`);
assert.dom(PAGE.badgeText.icon(icon)).exists(`status: ${status} renders icon: ${icon}`);
assert.dom(PAGE.badgeText.text).hasText(label, `status: ${status} renders label: ${label}`);
assert
.dom('[data-test-badge]')
.hasClass(`hds-badge--color-${color}`, `status: ${status} renders color: ${color}`);

View file

@ -23,44 +23,31 @@ module('Integration | Component | sync | Secrets::LandingCta', function (hooks)
this.transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
});
test('it should render promotional copy for community or enterprise version without feature', async function (assert) {
await render(hbs`<Secrets::LandingCta @isActivated={{false}} @hasSecretsSync={{false}} /> `, {
test('it should render promotional copy if feature is not activated', async function (assert) {
await render(hbs`<Secrets::LandingCta @isActivated={{false}} /> `, {
owner: this.engine,
});
assert
.dom(cta.summary)
.hasText(
'This premium enterprise feature allows you to sync secrets to platforms and tools across your stack to get secrets when and where you need them. Learn more about Secrets Sync'
'This feature allows you to sync secrets to platforms and tools across your stack to get secrets when and where you need them. Learn more about Secrets Sync'
);
assert.dom(cta.link).hasText('Learn more about Secrets Sync');
assert.dom(cta.button).doesNotExist('does not render create destination button');
});
test('it should render CTA copy but not action when feature exists on enterprise license and is not activated', async function (assert) {
await render(hbs`<Secrets::LandingCta @isActivated={{false}} @hasSecretsSync={{true}} /> `, {
owner: this.engine,
});
assert
.dom(cta.summary)
.hasText(
'Sync secrets to platforms and tools across your stack to get secrets when and where you need them. Secrets Sync tutorial'
);
assert.dom(cta.link).hasText('Secrets Sync tutorial');
assert.dom(cta.button).doesNotExist('does not render create destination button');
});
test('it should render CTA copy and action when feature exists on enterprise license and is activated', async function (assert) {
await render(hbs`<Secrets::LandingCta @isActivated={{true}} @hasSecretsSync={{true}} /> `, {
test('it should render CTA copy and action if feature is activated', async function (assert) {
await render(hbs`<Secrets::LandingCta @isActivated={{true}} /> `, {
owner: this.engine,
});
assert
.dom(cta.summary)
.hasText(
'Sync secrets to platforms and tools across your stack to get secrets when and where you need them. Secrets Sync tutorial'
'This feature allows you to sync secrets to platforms and tools across your stack to get secrets when and where you need them. Learn more about Secrets Sync'
);
assert.dom(cta.link).hasText('Secrets Sync tutorial');
assert.dom(cta.link).hasText('Learn more about Secrets Sync');
assert.dom(cta.button).exists('it renders create destination button');
});
});

View file

@ -15,6 +15,7 @@ import syncHandlers from 'vault/mirage/handlers/sync';
import { PAGE } from 'vault/tests/helpers/sync/sync-selectors';
import { Response } from 'miragejs';
import { dateFormat } from 'core/helpers/date-format';
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
const { title, tab, overviewCard, cta, overview, pagination, emptyStateTitle, emptyStateMessage } = PAGE;
@ -24,19 +25,24 @@ module('Integration | Component | sync | Page::Overview', function (hooks) {
setupMirage(hooks);
hooks.beforeEach(async function () {
// allow capabilities as root by default to allow users to POST to the secrets-sync/activate endpoint
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
this.version = this.owner.lookup('service:version');
this.store = this.owner.lookup('service:store');
this.version.type = 'enterprise';
this.version.features = ['Secrets Sync'];
syncScenario(this.server);
syncHandlers(this.server);
const store = this.owner.lookup('service:store');
this.destinations = await store.query('sync/destination', {});
this.destinations = await this.store.query('sync/destination', {});
this.isActivated = true;
this.licenseHasSecretsSync = true;
this.isHvdManaged = false;
this.renderComponent = () => {
return render(
hbs`<Secrets::Page::Overview @destinations={{this.destinations}} @totalVaultSecrets={{7}} @isActivated={{this.isActivated}} />`,
hbs`<Secrets::Page::Overview @destinations={{this.destinations}} @totalVaultSecrets={{7}} @isActivated={{this.isActivated}} @licenseHasSecretsSync={{this.licenseHasSecretsSync}} @isHvdManaged={{this.isHvdManaged}} />`,
{
owner: this.engine,
}
@ -57,9 +63,9 @@ module('Integration | Component | sync | Page::Overview', function (hooks) {
module('community', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'community';
this.version.features = [];
this.destinations = [];
this.isActivated = false;
this.licenseHasSecretsSync = false;
this.destinations = [];
});
test('it should show an upsell CTA', async function (assert) {
@ -69,11 +75,13 @@ module('Integration | Component | sync | Page::Overview', function (hooks) {
.dom(title)
.hasText('Secrets Sync Enterprise feature', 'page title indicates feature is only for Enterprise');
assert.dom(cta.button).doesNotExist();
assert.dom(cta.summary).exists();
});
});
module('ent', function (hooks) {
hooks.beforeEach(function () {
this.isActivated = false;
this.destinations = [];
});
@ -90,6 +98,7 @@ module('Integration | Component | sync | Page::Overview', function (hooks) {
test('it should show create CTA if license has the secrets sync feature', async function (assert) {
this.version.features = ['Secrets Sync'];
this.isActivated = true;
await this.renderComponent();
assert.dom(title).hasText('Secrets Sync');
@ -98,15 +107,72 @@ module('Integration | Component | sync | Page::Overview', function (hooks) {
});
});
module('secrets sync not activated', function (hooks) {
module('managed', function (hooks) {
hooks.beforeEach(function () {
this.isActivated = false;
this.isHvdManaged = true;
this.destinations = [];
});
test('it should show the opt-in banner if feature is not activated', async function (assert) {
await this.renderComponent();
assert.dom(overview.optInBanner).exists('Opt-in banner is shown');
});
test('it should not show the opt-in banner if feature is activated', async function (assert) {
this.isActivated = true;
await this.renderComponent();
assert.dom(overview.optInBanner).doesNotExist('Opt-in banner is not shown');
});
});
module('user does not have post permissions to activate', function (hooks) {
hooks.beforeEach(function () {
this.isActivated = false;
this.destinations = [];
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub(['read']));
});
test('it should show the opt-in banner without the ability to activate', async function (assert) {
await this.renderComponent();
assert
.dom(overview.optInBannerDescription)
.hasText(
'To use this feature, specific activation is required. Please contact your administrator to activate.'
);
assert.dom(overview.optInBannerEnable).doesNotExist('Opt-in enable button does not show');
});
test('it should not show allow the user to dismiss the opt-in banner', async function (assert) {
await this.renderComponent();
assert.dom(overview.optInDismiss).doesNotExist('dismiss opt-in banner does not show');
});
});
module('secrets sync is not activated and license has secrets sync meep', function (hooks) {
hooks.beforeEach(async function () {
this.isActivated = false;
});
test('it should show the opt-in banner', async function (assert) {
test('it should show the opt-in banner with activate description', async function (assert) {
await this.renderComponent();
assert.dom(overview.optInBanner).exists('Opt-in banner is shown');
assert
.dom(overview.optInBannerDescription)
.hasText(
"To use this feature, specific activation is required. Please review the feature documentation and enable it. If you're upgrading from beta, your previous data will be accessible after activation."
);
});
test('it should show dismiss banner', async function (assert) {
await this.renderComponent();
assert.dom(overview.optInDismiss).exists('dismiss opt-in banner shows');
});
test('it should navigate to the opt-in modal', async function (assert) {
@ -152,6 +218,18 @@ module('Integration | Component | sync | Page::Overview', function (hooks) {
});
});
module('secrets sync is not activated and license does not have secrets sync', function (hooks) {
hooks.beforeEach(async function () {
this.licenseHasSecretsSync = false;
});
test('it should hide the opt-in banner', async function (assert) {
await this.renderComponent();
assert.dom(overview.optInBanner).doesNotExist();
});
});
module('secrets sync is activated', function () {
test('it should hide the opt-in banner', async function (assert) {
await this.renderComponent();

View file

@ -18,6 +18,7 @@ module('Integration | Component | sync | SyncHeader', function (hooks) {
hooks.beforeEach(function () {
this.version = this.owner.lookup('service:version');
this.flags = this.owner.lookup('service:flags');
this.title = 'Secrets Sync';
this.renderComponent = () => {
return render(hbs`<SyncHeader @title={{this.title}} @breadcrumbs={{this.breadcrumbs}} />`, {
@ -64,6 +65,18 @@ module('Integration | Component | sync | SyncHeader', function (hooks) {
});
});
module('managed', function (hooks) {
hooks.beforeEach(function () {
this.version.type = 'enterprise';
this.flags.featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
});
test('it should render title and plus badge', async function (assert) {
await this.renderComponent();
assert.dom(title).hasText('Secrets Sync Plus feature');
});
});
test('it should yield actions block', async function (assert) {
await render(
hbs`

View file

@ -14,6 +14,10 @@ const ACTIVATED_FLAGS_RESPONSE = {
},
};
const FEATURE_FLAGS_RESPONSE = {
feature_flags: ['VAULT_CLOUD_ADMIN_NAMESPACE'],
};
module('Unit | Service | flags', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
@ -23,15 +27,10 @@ module('Unit | Service | flags', function (hooks) {
});
test('it loads with defaults', function (assert) {
assert.deepEqual(this.service.flags, [], 'Flags are empty until fetched');
assert.deepEqual(this.service.featureFlags, [], 'Flags are empty until fetched');
assert.deepEqual(this.service.activatedFlags, [], 'Activated flags are empty until fetched');
});
test('#setFeatureFlags: it can set feature flags', function (assert) {
this.service.setFeatureFlags(['foo', 'bar']);
assert.deepEqual(this.service.flags, ['foo', 'bar'], 'Flags are set');
});
module('#fetchActivatedFlags', function (hooks) {
hooks.beforeEach(function () {
this.owner.lookup('service:version').type = 'enterprise';
@ -75,22 +74,45 @@ module('Unit | Service | flags', function (hooks) {
});
});
module('#managedNamespaceRoot', function () {
module('#fetchFeatureFlags', function (hooks) {
hooks.beforeEach(function () {
this.owner.lookup('service:version').type = 'enterprise';
});
test('it returns feature flags', async function (assert) {
assert.expect(2);
this.server.get('sys/internal/ui/feature-flags', () => {
assert.true(true, 'GET request made to feature-flags endpoint');
return FEATURE_FLAGS_RESPONSE;
});
await this.service.fetchFeatureFlags();
assert.deepEqual(
this.service.featureFlags,
FEATURE_FLAGS_RESPONSE.feature_flags,
'Feature flags are fetched and set'
);
});
});
module('#hvdManagedNamespaceRoot', function () {
test('it returns null when flag is not present', function (assert) {
assert.strictEqual(this.service.managedNamespaceRoot, null);
assert.strictEqual(this.service.hvdManagedNamespaceRoot, null);
});
test('it returns the namespace root when flag is present', function (assert) {
this.service.setFeatureFlags(['VAULT_CLOUD_ADMIN_NAMESPACE']);
this.service.featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE'];
assert.strictEqual(
this.service.managedNamespaceRoot,
this.service.hvdManagedNamespaceRoot,
'admin',
'Managed namespace is admin when flag present'
);
this.service.setFeatureFlags(['SOMETHING_ELSE']);
this.service.featureFlags = ['SOMETHING_ELSE'];
assert.strictEqual(
this.service.managedNamespaceRoot,
this.service.hvdManagedNamespaceRoot,
null,
'Flags were overwritten and root namespace is null again'
);