diff --git a/changelog/27262.txt b/changelog/27262.txt new file mode 100644 index 0000000000..93c2fbe3f0 --- /dev/null +++ b/changelog/27262.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui/secrets-sync: Hide Secrets Sync from the sidebar nav if user does not have access to the feature. +``` diff --git a/ui/app/components/clients/counts/nav-bar.hbs b/ui/app/components/clients/counts/nav-bar.hbs index 04c6e028c2..3c4b75c3ce 100644 --- a/ui/app/components/clients/counts/nav-bar.hbs +++ b/ui/app/components/clients/counts/nav-bar.hbs @@ -15,7 +15,7 @@ Entity/Non-entity clients - {{#if @showSecretsSync}} + {{#if @showSecretsSyncClientCounts}}
  • Secrets sync clients diff --git a/ui/app/components/clients/page/counts.hbs b/ui/app/components/clients/page/counts.hbs index 512d729b91..91c401bda7 100644 --- a/ui/app/components/clients/page/counts.hbs +++ b/ui/app/components/clients/page/counts.hbs @@ -151,7 +151,7 @@ {{/if}} - + {{! CLIENT COUNT PAGE COMPONENTS RENDER HERE }} {{yield}} diff --git a/ui/app/components/clients/page/counts.ts b/ui/app/components/clients/page/counts.ts index 28fb5ae7b0..265c90da17 100644 --- a/ui/app/components/clients/page/counts.ts +++ b/ui/app/components/clients/page/counts.ts @@ -167,20 +167,10 @@ export default class ClientsCountsPageComponent extends Component { return activity?.total; } - get showSecretsSync(): boolean { + get hasSecretsSyncClients(): 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; + return activity && activity?.total?.secret_syncs > 0; } @action diff --git a/ui/app/components/sidebar/nav/cluster.hbs b/ui/app/components/sidebar/nav/cluster.hbs index ee01bbeede..b966cee198 100644 --- a/ui/app/components/sidebar/nav/cluster.hbs +++ b/ui/app/components/sidebar/nav/cluster.hbs @@ -13,12 +13,14 @@ @text="Secrets Engines" data-test-sidebar-nav-link="Secrets Engines" /> - + {{#if this.flags.showSecretsSync}} + + {{/if}} {{#if (has-permission "access")}} { - if (this.version.isCommunity) return; // Response could change between user sessions. // Fire off endpoint without checking if activated features are already set. + if (this.version.isCommunity) return; try { const response = await this.store .adapterFor('application') @@ -86,4 +88,21 @@ export default class flagsService extends Service { this.secretsSyncActivatePath.get('canUpdate') !== false ); } + + get showSecretsSync() { + const isHvdManaged = this.isHvdManaged; + const onLicense = this.version.hasSecretsSync; + const isEnterprise = this.version.isEnterprise; + const isActivated = this.secretsSyncIsActivated; + + if (!isEnterprise) return false; + if (isHvdManaged) return true; + if (isEnterprise && !onLicense) return false; + if (isActivated) { + // if the feature is activated but the user does not have permissions on the `sys/sync` endpoint, hide navigation link. + return this.permissions.hasNavPermission('sync'); + } + // only remaining option is Enterprise with Secrets Sync on the license but the feature is not activated. In this case, we want to show the upsell page and message about either activating or having an admin activate. + return true; + } } diff --git a/ui/app/services/permissions.js b/ui/app/services/permissions.js index 24c987bad2..2363ae45e8 100644 --- a/ui/app/services/permissions.js +++ b/ui/app/services/permissions.js @@ -49,6 +49,12 @@ const API_PATHS = { settings: { customMessages: 'sys/config/ui/custom-messages', }, + sync: { + destinations: 'sys/sync/destinations', + associations: 'sys/sync/associations', + config: 'sys/sync/config', + github: 'sys/sync/github-apps', + }, }; const API_PATHS_TO_ROUTE_PARAMS = { diff --git a/ui/lib/sync/addon/components/secrets/sync-activation-modal.ts b/ui/lib/sync/addon/components/secrets/sync-activation-modal.ts index b279e9db51..70fee2d750 100644 --- a/ui/lib/sync/addon/components/secrets/sync-activation-modal.ts +++ b/ui/lib/sync/addon/components/secrets/sync-activation-modal.ts @@ -43,7 +43,7 @@ export default class SyncActivationModal extends Component { .adapterFor('application') .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(); + yield this.router.refresh('vault.cluster'); } catch (error) { this.args.onError(errorMessage(error)); this.flashMessages.danger(`Error enabling feature \n ${errorMessage(error)}`); diff --git a/ui/lib/sync/addon/components/sync-header.hbs b/ui/lib/sync/addon/components/sync-header.hbs index 88a19e6ed3..afdcaccb8f 100644 --- a/ui/lib/sync/addon/components/sync-header.hbs +++ b/ui/lib/sync/addon/components/sync-header.hbs @@ -16,8 +16,8 @@ {{/if}} {{@title}} - {{#if this.badgeText}} - + {{#if this.flags.isHvdManaged}} + {{/if}} diff --git a/ui/lib/sync/addon/components/sync-header.ts b/ui/lib/sync/addon/components/sync-header.ts index 0d15499107..35d8dfc365 100644 --- a/ui/lib/sync/addon/components/sync-header.ts +++ b/ui/lib/sync/addon/components/sync-header.ts @@ -6,7 +6,6 @@ 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'; @@ -17,18 +16,5 @@ interface Args { } export default class SyncHeaderComponent extends Component { - @service declare readonly version: VersionService; @service declare readonly flags: FlagsService; - - get badgeText() { - 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 ''; - } } diff --git a/ui/lib/sync/addon/routes/secrets.ts b/ui/lib/sync/addon/routes/secrets.ts index cd38c7a252..0a1a6ec296 100644 --- a/ui/lib/sync/addon/routes/secrets.ts +++ b/ui/lib/sync/addon/routes/secrets.ts @@ -13,13 +13,8 @@ export default class SyncSecretsRoute extends Route { @service declare readonly router: RouterService; @service declare readonly flags: FlagService; - beforeModel() { - return this.flags.fetchActivatedFlags(); - } - model() { return { - // TODO will modify when we use the persona service. activatedFeatures: this.flags.activatedFlags, }; } diff --git a/ui/lib/sync/addon/routes/secrets/overview.ts b/ui/lib/sync/addon/routes/secrets/overview.ts index 52a81d1074..75b90ec502 100644 --- a/ui/lib/sync/addon/routes/secrets/overview.ts +++ b/ui/lib/sync/addon/routes/secrets/overview.ts @@ -8,10 +8,12 @@ import { service } from '@ember/service'; import { hash } from 'rsvp'; import type FlagsService from 'vault/services/flags'; +import type RouterService from '@ember/routing/router-service'; 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; @service declare readonly version: VersionService; @@ -34,4 +36,10 @@ export default class SyncSecretsOverviewRoute extends Route { : [], }); } + + redirect() { + if (!this.flags.showSecretsSync) { + this.router.replaceWith('vault.cluster.dashboard'); + } + } } diff --git a/ui/mirage/handlers/sync.js b/ui/mirage/handlers/sync.js index cf1eec2639..ae35dd35dc 100644 --- a/ui/mirage/handlers/sync.js +++ b/ui/mirage/handlers/sync.js @@ -7,6 +7,7 @@ import { Response } from 'miragejs'; import { camelize } from '@ember/string'; import { findDestination } from 'core/helpers/sync-destinations'; import clientsHandler from './clients'; +import modifyPassthroughResponse from '../helpers/modify-passthrough-response'; export const associationsResponse = (schema, req) => { const { type, name } = req.params; @@ -116,7 +117,9 @@ const createOrUpdateDestination = (schema, req) => { }; export default function (server) { - // default to activated + // default to enterprise with Secrets Sync on the license and activated + server.get('sys/health', (schema, req) => modifyPassthroughResponse(req, { enterprise: true })); + server.get('/sys/license/features', () => ({ features: ['Secrets Sync'] })); server.get('/sys/activation-flags', () => { return { data: { diff --git a/ui/mirage/helpers/modify-passthrough-response.js b/ui/mirage/helpers/modify-passthrough-response.js index c86a23eb07..b0ce762277 100644 --- a/ui/mirage/helpers/modify-passthrough-response.js +++ b/ui/mirage/helpers/modify-passthrough-response.js @@ -5,6 +5,7 @@ // passthrough request and modify response from server // pass object as second arg of properties in response to override +// ex: server.get('sys/health', (schema, req) => modifyPassthroughResponse(req, { enterprise: true })); export default function (req, props = {}) { return new Promise((resolve) => { const xhr = req.passthrough(); diff --git a/ui/tests/acceptance/clients/counts/overview-test.js b/ui/tests/acceptance/clients/counts/overview-test.js index 3bf134effa..2251f64db8 100644 --- a/ui/tests/acceptance/clients/counts/overview-test.js +++ b/ui/tests/acceptance/clients/counts/overview-test.js @@ -236,7 +236,7 @@ module('Acceptance | clients | overview | sync in license, activated', function }); test('it should render the correct tabs', async function (assert) { - assert.dom(GENERAL.tab('sync')).exists(); + assert.dom(GENERAL.tab('sync')).exists('shows the sync tab'); }); test('it should show secrets sync stats', async function (assert) { diff --git a/ui/tests/acceptance/clients/counts/sync-test.js b/ui/tests/acceptance/clients/counts/sync-test.js index 6dfb2a8238..82868d79b2 100644 --- a/ui/tests/acceptance/clients/counts/sync-test.js +++ b/ui/tests/acceptance/clients/counts/sync-test.js @@ -22,6 +22,8 @@ module('Acceptance | clients | sync', function (hooks) { hooks.beforeEach(async function () { sinon.replace(timestamp, 'now', sinon.fake.returns(STATIC_NOW)); syncHandler(this.server); + const version = this.owner.lookup('service:version'); + version.type = 'enterprise'; await authPage.login(); return visit('/vault/clients/counts/sync'); }); @@ -34,22 +36,31 @@ module('Acceptance | clients | sync', function (hooks) { }); }); - module('sync not activated', function (hooks) { + module('sync not activated and on license', 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)); + syncHandler(this.server); + server.get('/sys/activation-flags', () => { + return { + data: { + activated: [''], + unactivated: ['secrets-sync'], + }, + }; + }); 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); + assert.expect(2); this.server.get('/sys/activation-flags', () => { assert.true(true, '/sys/activation-flags/ is called to check if secrets-sync is activated'); - + // called once from the higher level cluster route return { data: { activated: [], diff --git a/ui/tests/acceptance/sync/secrets/destination-test.js b/ui/tests/acceptance/sync/secrets/destination-test.js index 452e3b4578..66404f0d1d 100644 --- a/ui/tests/acceptance/sync/secrets/destination-test.js +++ b/ui/tests/acceptance/sync/secrets/destination-test.js @@ -14,7 +14,7 @@ import { settled, click, visit, currentURL, fillIn, currentRouteName } from '@em import { PAGE as ts } from 'vault/tests/helpers/sync/sync-selectors'; // sync is an enterprise feature but since mirage is used the enterprise label has been intentionally omitted from the module name -module('Acceptance | sync | destination', function (hooks) { +module('Acceptance | sync | destination (singular)', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); diff --git a/ui/tests/acceptance/sync/secrets/destinations-test.js b/ui/tests/acceptance/sync/secrets/destinations-test.js index 3cca6b7a43..71b090d15d 100644 --- a/ui/tests/acceptance/sync/secrets/destinations-test.js +++ b/ui/tests/acceptance/sync/secrets/destinations-test.js @@ -16,7 +16,7 @@ import { syncDestinations } from 'vault/helpers/sync-destinations'; const SYNC_DESTINATIONS = syncDestinations(); // sync is an enterprise feature but since mirage is used the enterprise label has been intentionally omitted from the module name -module('Acceptance | sync | destinations', function (hooks) { +module('Acceptance | sync | destinations (plural)', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); diff --git a/ui/tests/acceptance/sync/secrets/overview-test.js b/ui/tests/acceptance/sync/secrets/overview-test.js index 147c687b56..10a81c88d1 100644 --- a/ui/tests/acceptance/sync/secrets/overview-test.js +++ b/ui/tests/acceptance/sync/secrets/overview-test.js @@ -8,6 +8,7 @@ import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; import syncScenario from 'vault/mirage/scenarios/sync'; import syncHandlers from 'vault/mirage/handlers/sync'; +import sinon from 'sinon'; import authPage from 'vault/tests/pages/auth'; import { click, waitFor, visit, currentURL } from '@ember/test-helpers'; import { PAGE as ts } from 'vault/tests/helpers/sync/sync-selectors'; @@ -19,198 +20,288 @@ module('Acceptance | sync | overview', function (hooks) { setupMirage(hooks); hooks.beforeEach(async function () { - syncHandlers(this.server); this.version = this.owner.lookup('service:version'); - this.version.features = ['Secrets Sync']; - - await authPage.login(); + this.permissions = this.owner.lookup('service:permissions'); }); - module('when feature is activated', function (hooks) { + module('ent', function (hooks) { hooks.beforeEach(async function () { - syncScenario(this.server); + this.version.type = 'enterprise'; }); - test('it fetches destinations and associations', async function (assert) { - assert.expect(2); - - this.server.get('/sys/sync/destinations', () => { - assert.true(true, 'destinations is called'); - }); - this.server.get('/sys/sync/associations', () => { - assert.true(true, 'associations is called'); - }); - - await visit('/vault/sync/secrets/overview'); - }); - - module('when there are pre-existing destinations', function (hooks) { + module('sync on license', function (hooks) { hooks.beforeEach(async function () { - syncScenario(this.server); + this.version.features = ['Secrets Sync']; }); - test('it should transition to correct routes when performing actions', async function (assert) { - await click(ts.navLink('Secrets Sync')); - await click(ts.destinations.list.create); - await click(ts.createCancel); - await click(ts.overviewCard.actionLink('Create new')); - await click(ts.createCancel); - await waitFor(ts.overview.table.actionToggle(0)); - await click(ts.overview.table.actionToggle(0)); - await click(ts.overview.table.action('sync')); - await click(ts.destinations.sync.cancel); - await click(ts.breadcrumbLink('Secrets Sync')); - await waitFor(ts.overview.table.actionToggle(0)); - await click(ts.overview.table.actionToggle(0)); - await click(ts.overview.table.action('details')); - assert.dom(ts.tab('Secrets')).hasClass('active', 'Navigates to secrets view for destination'); + module('when feature is activated', function (hooks) { + hooks.beforeEach(async function () { + syncHandlers(this.server); + await authPage.login(); + }); + + test('it fetches destinations and associations', async function (assert) { + assert.expect(2); + + this.server.get('/sys/sync/destinations', () => { + assert.true(true, 'destinations is called'); + }); + this.server.get('/sys/sync/associations', () => { + assert.true(true, 'associations is called'); + }); + + await visit('/vault/sync/secrets/overview'); + }); + + module('when there are pre-existing destinations', function (hooks) { + hooks.beforeEach(async function () { + syncScenario(this.server); + await authPage.login(); + }); + + test('it should transition to correct routes when performing actions', async function (assert) { + await click(ts.navLink('Secrets Sync')); + await click(ts.destinations.list.create); + await click(ts.createCancel); + await click(ts.overviewCard.actionLink('Create new')); + await click(ts.createCancel); + await waitFor(ts.overview.table.actionToggle(0)); + await click(ts.overview.table.actionToggle(0)); + await click(ts.overview.table.action('sync')); + await click(ts.destinations.sync.cancel); + await click(ts.breadcrumbLink('Secrets Sync')); + await waitFor(ts.overview.table.actionToggle(0)); + await click(ts.overview.table.actionToggle(0)); + await click(ts.overview.table.action('details')); + assert.dom(ts.tab('Secrets')).hasClass('active', 'Navigates to secrets view for destination'); + }); + }); + + module('permissions', function () { + test('users without permissions - denies access to sync page', async function (assert) { + const hasNavPermission = sinon.stub(this.permissions, 'hasNavPermission'); + hasNavPermission.returns(false); + + await visit('/vault/sync/secrets/overview'); + + assert.strictEqual(currentURL(), '/vault/dashboard', 'redirects to cluster dashboard route'); + }); + + test('users with permissions - allows access to sync page', async function (assert) { + const hasNavPermission = sinon.stub(this.permissions, 'hasNavPermission'); + hasNavPermission.returns(true); + + await visit('/vault/sync/secrets/overview'); + + assert.strictEqual( + currentURL(), + '/vault/sync/secrets/overview', + 'stays on the sync overview route' + ); + }); + }); + }); + + module('when feature is not activated', function (hooks) { + hooks.beforeEach(async function () { + let wasActivatePOSTCalled = false; + // simulate the feature being activated once /secrets-sync/activate has been called + this.server.get('/sys/activation-flags', () => { + if (wasActivatePOSTCalled) { + return { + data: { + activated: ['secrets-sync'], + unactivated: [''], + }, + }; + } else { + return { + data: { + activated: [''], + unactivated: ['secrets-sync'], + }, + }; + } + }); + + this.server.post('/sys/activation-flags/secrets-sync/activate', () => { + wasActivatePOSTCalled = true; + return {}; + }); + await authPage.login(); + }); + + test('it does not fetch destinations and associations', async function (assert) { + assert.expect(0); + + this.server.get('/sys/sync/destinations', () => { + assert.true(false, 'destinations is not called'); + }); + this.server.get('/sys/sync/associations', () => { + assert.true(false, 'associations is not called'); + }); + + await visit('/vault/sync/secrets/overview'); + }); + + test('the activation workflow works', async function (assert) { + await visit('/vault/sync/secrets/overview'); + + assert + .dom(ts.cta.button) + .doesNotExist('create first destination is not available until feature has been activated'); + + assert.dom(ts.overview.optInBanner.container).exists(); + await click(ts.overview.optInBanner.enable); + + assert + .dom(ts.overview.activationModal.container) + .exists('modal to opt-in and activate feature is shown'); + await click(ts.overview.activationModal.checkbox); + await click(ts.overview.activationModal.confirm); + + assert + .dom(ts.overview.activationModal.container) + .doesNotExist('modal is gone once activation has been submitted'); + assert + .dom(ts.overview.optInBanner.container) + .doesNotExist('opt-in banner is gone once activation has been submitted'); + + await click(ts.cta.button); + assert.strictEqual( + currentURL(), + '/vault/sync/secrets/destinations/create', + 'create new destination is available once feature is activated' + ); + }); + + module('enterprise with namespaces', function (hooks) { + hooks.beforeEach(async function () { + this.version.features = ['Secrets Sync', 'Namespaces']; + + await runCmd(`write sys/namespaces/admin -f`, false); + await authPage.loginNs('admin'); + await runCmd(`write sys/namespaces/foo -f`, false); + await authPage.loginNs('admin/foo'); + }); + + test('it should make activation-flag requests to correct namespace', async function (assert) { + assert.expect(3); + + this.server.get('/sys/activation-flags', (_, req) => { + assert.deepEqual(req.requestHeaders, {}, 'Request is unauthenticated and in root namespace'); + return { + data: { + activated: [''], + unactivated: ['secrets-sync'], + }, + }; + }); + this.server.post('/sys/activation-flags/secrets-sync/activate', (_, req) => { + assert.strictEqual( + req.requestHeaders['X-Vault-Namespace'], + undefined, + 'Request is made to undefined namespace' + ); + return {}; + }); + + // confirm we're in admin/foo + assert.dom('[data-test-badge-namespace]').hasText('foo'); + await click(ts.navLink('Secrets Sync')); + await click(ts.overview.optInBanner.enable); + await click(ts.overview.activationModal.checkbox); + await click(ts.overview.activationModal.confirm); + }); + + test('it should make activation-flag requests to correct namespace when managed', async function (assert) { + assert.expect(3); + 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'); + return { + data: { + activated: [''], + unactivated: ['secrets-sync'], + }, + }; + }); + this.server.post('/sys/activation-flags/secrets-sync/activate', (_, req) => { + assert.strictEqual( + req.requestHeaders['X-Vault-Namespace'], + 'admin', + 'Request is made to the admin namespace' + ); + return {}; + }); + + // confirm we're in admin/foo + assert.dom('[data-test-badge-namespace]').hasText('foo'); + + await click(ts.navLink('Secrets Sync')); + await click(ts.overview.optInBanner.enable); + await click(ts.overview.activationModal.checkbox); + await click(ts.overview.activationModal.confirm); + }); + }); + + module('permissions', function () { + test('users without permissions - allows access to sync page', async function (assert) { + const hasNavPermission = sinon.stub(this.permissions, 'hasNavPermission'); + hasNavPermission.returns(false); + + await visit('/vault/sync/secrets/overview'); + + assert.strictEqual( + currentURL(), + '/vault/sync/secrets/overview', + 'stays on the sync overview route' + ); + }); + + test('users with permissions - allows access to sync page', async function (assert) { + const hasNavPermission = sinon.stub(this.permissions, 'hasNavPermission'); + hasNavPermission.returns(true); + + await visit('/vault/sync/secrets/overview'); + + assert.strictEqual( + currentURL(), + '/vault/sync/secrets/overview', + 'stays on the sync overview route' + ); + }); + }); + }); + }); + + module('sync NOT on license', function (hooks) { + hooks.beforeEach(async function () { + await authPage.login(); + + // reset features *after* login, since the login process will set the initial value according to the actual license + this.version.features = []; + }); + + test('it should not allow access to sync page', async function (assert) { + await visit('/vault/sync/secrets/overview'); + + assert.strictEqual(currentURL(), '/vault/dashboard', 'redirects to cluster dashboard route'); }); }); }); - module('when feature is not activated', function (hooks) { + module('oss', function (hooks) { hooks.beforeEach(async function () { - let wasActivatePOSTCalled = false; - - // simulate the feature being activated once /secrets-sync/activate has been called - this.server.get('/sys/activation-flags', () => { - if (wasActivatePOSTCalled) { - return { - data: { - activated: ['secrets-sync'], - unactivated: [''], - }, - }; - } else { - return { - data: { - activated: [''], - unactivated: ['secrets-sync'], - }, - }; - } - }); - - this.server.post('/sys/activation-flags/secrets-sync/activate', () => { - wasActivatePOSTCalled = true; - return {}; - }); + this.version.type = 'community'; + await authPage.login(); }); - test('it does not fetch destinations and associations', async function (assert) { - assert.expect(0); - - this.server.get('/sys/sync/destinations', () => { - assert.true(false, 'destinations is not called'); - }); - this.server.get('/sys/sync/associations', () => { - assert.true(false, 'associations is not called'); - }); - - await visit('/vault/sync/secrets/overview'); - }); - - test('the activation workflow works', async function (assert) { + test('it should not allow access to sync page', async function (assert) { await visit('/vault/sync/secrets/overview'); - assert - .dom(ts.cta.button) - .doesNotExist('create first destination is not available until feature has been activated'); - - assert.dom(ts.overview.optInBanner.container).exists(); - await click(ts.overview.optInBanner.enable); - - assert - .dom(ts.overview.activationModal.container) - .exists('modal to opt-in and activate feature is shown'); - await click(ts.overview.activationModal.checkbox); - await click(ts.overview.activationModal.confirm); - - assert - .dom(ts.overview.activationModal.container) - .doesNotExist('modal is gone once activation has been submitted'); - assert - .dom(ts.overview.optInBanner.container) - .doesNotExist('opt-in banner is gone once activation has been submitted'); - - await click(ts.cta.button); - assert.strictEqual( - currentURL(), - '/vault/sync/secrets/destinations/create', - 'create new destination is available once feature is activated' - ); - }); - }); - - module('enterprise with namespaces', function (hooks) { - hooks.beforeEach(async function () { - this.version.features = ['Secrets Sync', 'Namespaces']; - await runCmd(`write sys/namespaces/admin -f`, false); - await authPage.loginNs('admin'); - await runCmd(`write sys/namespaces/foo -f`, false); - await authPage.loginNs('admin/foo'); - }); - - test('it should make activation-flag requests to correct namespace', async function (assert) { - assert.expect(4); - // should call GET activation-flags twice because we need an updated response after activating the feature - this.server.get('/sys/activation-flags', (_, req) => { - assert.deepEqual(req.requestHeaders, {}, 'Request is unauthenticated and in root namespace'); - return { - data: { - activated: [''], - unactivated: ['secrets-sync'], - }, - }; - }); - this.server.post('/sys/activation-flags/secrets-sync/activate', (_, req) => { - assert.strictEqual( - req.requestHeaders['X-Vault-Namespace'], - undefined, - 'Request is made to undefined namespace' - ); - return {}; - }); - - // confirm we're in admin/foo - assert.dom('[data-test-badge-namespace]').hasText('foo'); - - await click(ts.navLink('Secrets Sync')); - await click(ts.overview.optInBanner.enable); - await click(ts.overview.activationModal.checkbox); - await click(ts.overview.activationModal.confirm); - }); - - 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'); - return { - data: { - activated: [''], - unactivated: ['secrets-sync'], - }, - }; - }); - this.server.post('/sys/activation-flags/secrets-sync/activate', (_, req) => { - assert.strictEqual( - req.requestHeaders['X-Vault-Namespace'], - 'admin', - 'Request is made to the admin namespace' - ); - return {}; - }); - - // confirm we're in admin/foo - assert.dom('[data-test-badge-namespace]').hasText('foo'); - - await click(ts.navLink('Secrets Sync')); - await click(ts.overview.optInBanner.enable); - await click(ts.overview.activationModal.checkbox); - await click(ts.overview.activationModal.confirm); + assert.strictEqual(currentURL(), '/vault/dashboard', 'redirects to cluster dashboard route'); }); }); }); diff --git a/ui/tests/integration/components/clients/counts/nav-bar-test.js b/ui/tests/integration/components/clients/counts/nav-bar-test.js index eed377b88f..a8dcaf9798 100644 --- a/ui/tests/integration/components/clients/counts/nav-bar-test.js +++ b/ui/tests/integration/components/clients/counts/nav-bar-test.js @@ -13,10 +13,12 @@ module('Integration | Component | clients/counts/nav-bar', function (hooks) { setupRenderingTest(hooks); hooks.beforeEach(function () { - this.showSecretsSync = false; + this.showSecretsSyncClientCounts = false; this.renderComponent = async () => { - await render(hbs``); + await render( + hbs`` + ); }; }); @@ -28,15 +30,15 @@ module('Integration | Component | clients/counts/nav-bar', function (hooks) { assert.dom(GENERAL.tab('acme')).hasText('ACME clients'); }); - test('it shows secrets sync tab if showSecretsSync is true', async function (assert) { - this.showSecretsSync = true; + test('it shows secrets sync tab if showSecretsSyncClientCounts is true', async function (assert) { + this.showSecretsSyncClientCounts = 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; + test('it should not show secrets sync tab if showSecretsSyncClientCounts is false', async function (assert) { + this.showSecretsSyncClientCounts = false; await this.renderComponent(); assert.dom(GENERAL.tab('sync')).doesNotExist(); diff --git a/ui/tests/integration/components/sidebar/nav/cluster-test.js b/ui/tests/integration/components/sidebar/nav/cluster-test.js index 9e76b280b1..f36209c96e 100644 --- a/ui/tests/integration/components/sidebar/nav/cluster-test.js +++ b/ui/tests/integration/components/sidebar/nav/cluster-test.js @@ -52,7 +52,7 @@ module('Integration | Component | sidebar-nav-cluster', function (hooks) { assert .dom('[data-test-sidebar-nav-link]') - .exists({ count: 3 }, 'Nav links are hidden other than secrets, secrets sync and dashboard'); + .exists({ count: 2 }, 'Nav links are hidden other than secrets and dashboard'); assert .dom('[data-test-sidebar-nav-heading]') .exists({ count: 1 }, 'Headings are hidden other than Vault'); @@ -84,31 +84,6 @@ 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']; - // 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) => { - assert - .dom(`[data-test-sidebar-nav-link="${link}"]`) - .hasText(`${link} Enterprise`, `${link} link renders Enterprise badge`); - }); - }); - - test('it should render badge for promotional links on enterprise version', async function (assert) { - const promotionalLinks = ['Secrets Sync']; - stubFeaturesAndPermissions(this.owner, true, true, ['Namespaces']); - await renderComponent(); - - promotionalLinks.forEach((link) => { - assert - .dom(`[data-test-sidebar-nav-link="${link}"]`) - .hasText(`${link} Premium`, `${link} link renders Premium badge`); - }); - }); - test('it should hide enterprise related links in child namespace', async function (assert) { const links = [ 'Disaster Recovery', diff --git a/ui/tests/integration/components/sync/secrets/page/overview-test.js b/ui/tests/integration/components/sync/secrets/page/overview-test.js index 8684f662a6..e4dddd3fdc 100644 --- a/ui/tests/integration/components/sync/secrets/page/overview-test.js +++ b/ui/tests/integration/components/sync/secrets/page/overview-test.js @@ -61,42 +61,12 @@ module('Integration | Component | sync | Page::Overview', function (hooks) { assert.dom(overview.createDestination).hasText('Create new destination', 'Toolbar action renders'); }); - module('community', function (hooks) { - hooks.beforeEach(function () { - this.version.type = 'community'; - this.isActivated = false; - this.licenseHasSecretsSync = false; - this.destinations = []; - }); - - test('it should show an upsell CTA', async function (assert) { - await this.renderComponent(); - - assert - .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 = []; }); - test('it should show an upsell CTA if license does NOT have the secrets sync feature', async function (assert) { - this.version.features = []; - await this.renderComponent(); - - assert - .dom(title) - .hasText('Secrets Sync Premium feature', 'title indicates feature is only for Premium'); - assert.dom(cta.button).doesNotExist(); - assert.dom(cta.summary).exists(); - }); - test('it should show create CTA if license has the secrets sync feature', async function (assert) { this.version.features = ['Secrets Sync']; this.isActivated = true; @@ -182,7 +152,7 @@ module('Integration | Component | sync | Page::Overview', function (hooks) { }); }); - module('secrets sync is not activated and license has secrets sync', function (hooks) { + module('secrets sync is not activated', function (hooks) { hooks.beforeEach(async function () { this.isActivated = false; }); @@ -263,18 +233,6 @@ 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.container).doesNotExist(); - }); - }); - module('secrets sync is activated', function () { test('it should hide the opt-in banner', async function (assert) { await this.renderComponent(); diff --git a/ui/tests/integration/components/sync/sync-header-test.js b/ui/tests/integration/components/sync/sync-header-test.js index 7f387bcb9e..95d1144684 100644 --- a/ui/tests/integration/components/sync/sync-header-test.js +++ b/ui/tests/integration/components/sync/sync-header-test.js @@ -44,25 +44,6 @@ module('Integration | Component | sync | SyncHeader', function (hooks) { assert.dom(title).hasText('Secrets Sync'); }); - - test('it should render title and premium badge if license does not have secrets sync feature', async function (assert) { - this.version.features = []; - await this.renderComponent(); - - assert.dom(title).hasText('Secrets Sync Premium feature'); - }); - }); - - module('community', function (hooks) { - hooks.beforeEach(function () { - this.version.type = 'community'; - }); - - test('it should render title and enterprise badge', async function (assert) { - await this.renderComponent(); - - assert.dom(title).hasText('Secrets Sync Enterprise feature'); - }); }); module('managed', function (hooks) { diff --git a/ui/tests/unit/services/flags-test.js b/ui/tests/unit/services/flags-test.js index 782a3fb343..610c60e8cb 100644 --- a/ui/tests/unit/services/flags-test.js +++ b/ui/tests/unit/services/flags-test.js @@ -6,6 +6,7 @@ import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; +import sinon from 'sinon'; const ACTIVATED_FLAGS_RESPONSE = { data: { @@ -24,6 +25,8 @@ module('Unit | Service | flags', function (hooks) { hooks.beforeEach(function () { this.service = this.owner.lookup('service:flags'); + this.version = this.owner.lookup('service:version'); + this.permissions = this.owner.lookup('service:permissions'); }); test('it loads with defaults', function (assert) { @@ -33,7 +36,7 @@ module('Unit | Service | flags', function (hooks) { module('#fetchActivatedFlags', function (hooks) { hooks.beforeEach(function () { - this.owner.lookup('service:version').type = 'enterprise'; + this.version.type = 'enterprise'; }); test('it returns activated flags', async function (assert) { @@ -66,8 +69,16 @@ module('Unit | Service | flags', function (hooks) { assert.deepEqual(this.service.activatedFlags, [], 'Activated flags are empty'); }); - test('it returns an empty array if the cluster is OSS', async function (assert) { - this.owner.lookup('service:version').type = 'community'; + test('it does not call activation-flags endpoint if the cluster is OSS', async function (assert) { + this.version.type = 'community'; + + this.server.get( + 'sys/activation-flags', + () => + new Error( + 'uh oh! a request was made to sys/activation-flags, this should not happen for community versions' + ) + ); await this.service.fetchActivatedFlags(); assert.deepEqual(this.service.activatedFlags, [], 'Activated flags are empty'); @@ -76,7 +87,7 @@ module('Unit | Service | flags', function (hooks) { module('#fetchFeatureFlags', function (hooks) { hooks.beforeEach(function () { - this.owner.lookup('service:version').type = 'enterprise'; + this.version.type = 'enterprise'; }); test('it returns feature flags', async function (assert) { @@ -119,9 +130,9 @@ module('Unit | Service | flags', function (hooks) { }); }); - module('#secretsSyncActivated', function (hooks) { + module('#secretsSyncIsActivated', function (hooks) { hooks.beforeEach(function () { - this.owner.lookup('service:version').type = 'enterprise'; + this.version.type = 'enterprise'; this.service.activatedFlags = ACTIVATED_FLAGS_RESPONSE.data.activated; }); @@ -134,4 +145,78 @@ module('Unit | Service | flags', function (hooks) { assert.false(this.service.secretsSyncIsActivated); }); }); + + module('#showSecretsSync', function () { + test('it returns false when version is community', function (assert) { + this.version.type = 'community'; + assert.false(this.service.showSecretsSync); + }); + + module('isHvdManaged', function (hooks) { + hooks.beforeEach(function () { + this.version.type = 'enterprise'; + this.service.featureFlags = ['VAULT_CLOUD_ADMIN_NAMESPACE']; + }); + + test('it returns true when not activated', function (assert) { + this.service.activatedFlags = []; + assert.true(this.service.showSecretsSync); + }); + + test('it returns true when activated', function (assert) { + this.service.activatedFlags = ACTIVATED_FLAGS_RESPONSE.data.activated; + assert.true(this.service.showSecretsSync); + }); + }); + + module('is Enterprise', function (hooks) { + hooks.beforeEach(function () { + this.version.type = 'enterprise'; + }); + + test('it returns false when not on license ', function (assert) { + this.version.features = ['replication']; + assert.false(this.service.showSecretsSync); + }); + + module('no permissions to sys/sync', function (hooks) { + hooks.beforeEach(function () { + this.version.features = ['Secrets Sync']; + const hasNavPermission = sinon.stub(this.permissions, 'hasNavPermission'); + hasNavPermission.returns(false); + }); + + test('it returns false when activated ', function (assert) { + this.service.activatedFlags = ACTIVATED_FLAGS_RESPONSE.data.activated; + assert.false(this.service.showSecretsSync); + }); + + test('it returns true when not activated ', function (assert) { + // the activate endpoint is located at a different path than sys/sync. + // the expected UX experience: if the feature is not activated, regardless of permissions + // the user should see the landing page and a banner that tells them to either have an admin activate the feature or activate it themselves + this.service.activatedFlags = []; + assert.true(this.service.showSecretsSync); + }); + }); + + module('user has permissions to sys/sync', function (hooks) { + hooks.beforeEach(function () { + this.version.features = ['Secrets Sync']; + const hasNavPermission = sinon.stub(this.permissions, 'hasNavPermission'); + hasNavPermission.returns(true); + }); + + test('it returns true when activated ', function (assert) { + this.service.activatedFlags = ACTIVATED_FLAGS_RESPONSE.data.activated; + assert.true(this.service.showSecretsSync); + }); + + test('it returns true when not activated ', function (assert) { + this.service.activatedFlags = []; + assert.true(this.service.showSecretsSync); + }); + }); + }); + }); }); diff --git a/ui/types/vault/services/permissions.d.ts b/ui/types/vault/services/permissions.d.ts index 232508240c..99181ab722 100644 --- a/ui/types/vault/services/permissions.d.ts +++ b/ui/types/vault/services/permissions.d.ts @@ -16,4 +16,5 @@ export default class PermissionsService extends Service { canViewAll: boolean | null; permissionsBanner: string | null; chrootNamespace: string | null | undefined; + hasNavPermission: (string) => boolean; }