From 0400a442c0b81034ccab4f972c7a9f0ca02edc0c Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Tue, 23 Sep 2025 19:12:01 -0400 Subject: [PATCH] UI: Skip recovery requests for community versions (#9555) (#9588) * use "redirect" instead of "afterModel" * fix styling of radio group buttons * remove redundant route redirect * wrap mount dropdown in loading conditional * reuse parent redirect logic, delete unused outlet * minor padding adjustments * force restart tests Co-authored-by: claire bontempo <68122737+hellobontempo@users.noreply.github.com> --- ui/app/components/recovery/page/snapshots.hbs | 23 ++++- ui/app/components/recovery/page/snapshots.ts | 17 +--- .../recovery/page/snapshots/load.hbs | 46 ++++++---- .../page/snapshots/snapshot-manage.hbs | 3 +- .../vault/cluster/recovery/snapshots.ts | 28 +++--- .../vault/cluster/recovery/snapshots/index.ts | 14 +-- .../vault/cluster/recovery/error.hbs | 6 +- .../vault/cluster/recovery/snapshots.hbs | 6 -- .../acceptance/recovery/snapshots-test.js | 91 ++++++++++++++----- ui/tests/helpers/general-selectors.ts | 1 + .../recovery/page/snapshot-manage-test.js | 6 +- .../recovery/page/snapshots-test.js | 18 ++-- 12 files changed, 150 insertions(+), 109 deletions(-) delete mode 100644 ui/app/templates/vault/cluster/recovery/snapshots.hbs diff --git a/ui/app/components/recovery/page/snapshots.hbs b/ui/app/components/recovery/page/snapshots.hbs index 043e4dc278..0002f31532 100644 --- a/ui/app/components/recovery/page/snapshots.hbs +++ b/ui/app/components/recovery/page/snapshots.hbs @@ -8,9 +8,24 @@ @subtitle="Recover lost or deleted data from a raft snapshot. Supported data includes KV v1 and Cubbyhole secrets or Database static roles." /> -{{! Currently, only a single snapshot is supported. In the future, this may change to support multiple loaded snapshots -and a LIST view will be used then. }} -{{#unless @model.snapshots}} +{{#if @model.showCommunityMessage}} + + + +{{else if (not @model.snapshots)}} + {{! Currently, only a single snapshot is supported and the UI automatically redirects users to "recovery.snapshots.snapshot.manage" if one exists. + In the future, this may change to support multiple loaded snapshots and a LIST view will be built then. }} {{#let (get this.emptyStateDetails this.state) as |d|}} @@ -30,4 +45,4 @@ and a LIST view will be used then. }} {{/if}} {{/let}} -{{/unless}} \ No newline at end of file +{{/if}} \ No newline at end of file diff --git a/ui/app/components/recovery/page/snapshots.ts b/ui/app/components/recovery/page/snapshots.ts index e3abb0d5d2..eec851b440 100644 --- a/ui/app/components/recovery/page/snapshots.ts +++ b/ui/app/components/recovery/page/snapshots.ts @@ -7,10 +7,8 @@ import Component from '@glimmer/component'; import { service } from '@ember/service'; import type NamespaceService from 'vault/services/namespace'; -import type VersionService from 'vault/services/version'; enum State { - COMMUNITY_VERSION = 'community', NON_ROOT_NAMESPACE = 'non-root-namespace', ALLOW_UPLOAD = 'default', CANNOT_UPLOAD = 'cannot-upload', @@ -21,22 +19,11 @@ interface Args { } export default class Index extends Component { - @service declare readonly version: VersionService; @service declare readonly namespace: NamespaceService; viewState = State; emptyStateDetails = { - [this.viewState.COMMUNITY_VERSION]: { - title: 'Secrets Recovery is an enterprise feature', - icon: 'sync-reverse', - message: - 'Secrets Recovery allows you to restore accidentally deleted or lost secrets from a snapshot. The snapshots can be provided via upload or loaded from external storage.', - buttonText: 'Learn more about upgrading', - buttonHref: '/vault/docs/enterprise', - buttonIcon: 'docs-link', - buttonColor: 'tertiary', - }, [this.viewState.NON_ROOT_NAMESPACE]: { title: 'Snapshot upload is restricted', icon: 'sync-reverse', @@ -70,9 +57,7 @@ export default class Index extends Component { get state() { const { canLoadSnapshot } = this.args.model; - if (this.version.isCommunity) { - return this.viewState.COMMUNITY_VERSION; - } else if (!this.namespace.inRootNamespace) { + if (!this.namespace.inRootNamespace) { return this.viewState.NON_ROOT_NAMESPACE; } else if (!canLoadSnapshot) { return this.viewState.CANNOT_UPLOAD; diff --git a/ui/app/components/recovery/page/snapshots/load.hbs b/ui/app/components/recovery/page/snapshots/load.hbs index b4696de7af..0080e9cfcb 100644 --- a/ui/app/components/recovery/page/snapshots/load.hbs +++ b/ui/app/components/recovery/page/snapshots/load.hbs @@ -14,13 +14,11 @@ {{/if}} -
- - Choose how to provide the snapshot - -
- + + Choose how to provide the snapshot + Provide the snapshot URL from a configured cloud storage - -
-
- + Upload a new snapshot to the disk - -
-
+ + + + +
{{#if (eq this.selectedLoadMethod this.loadMethods.AUTOMATED)}}
@@ -76,7 +74,13 @@ > Snapshot configuration name - Name of the configuration that created the snapshot. Existing automated snapshots should be configured via /sys + Name of the configuration that created the snapshot. Existing automated snapshots should be configured via the + automated snapshots config endpoint. {{F.options}} @@ -94,7 +98,13 @@ > Snapshot configuration name - Name of the configuration that created the snapshot. Existing automated snapshots should be configured via /sys + Name of the configuration that created the snapshot. Existing automated snapshots should be configured via the + automated snapshots config endpoint. {{#if this.configError}} @@ -123,7 +133,7 @@
{{else}} -Recover or read data +Recover or read data {{#if this.recoveryData}} Success @@ -82,6 +82,7 @@ {{/if}} {{! Allow for manual entry of mount paths if no mounts are returned (such as when the user does not have LIST permissions) }} + {{!-- {{#if (or this.mountOptions this.fetchMounts.isRunning)}} --}} {{#if this.mountOptions}} ; @@ -19,24 +19,26 @@ export default class RecoverySnapshotsRoute extends Route { @service declare readonly api: ApiService; @service declare readonly capabilities: Capabilities; @service declare readonly router: RouterService; + @service declare readonly version: VersionService; async model() { - const { canUpdate } = await this.capabilities.fetchPathCapabilities( - 'sys/storage/raft/snapshot/snapshot-load' - ); + if (this.version.isEnterprise) { + const { canUpdate } = await this.capabilities.fetchPathCapabilities( + 'sys/storage/raft/snapshot/snapshot-load' + ); - const snapshots = await this.fetchSnapshots(); + const snapshots = await this.fetchSnapshots(); - return { - snapshots, - canLoadSnapshot: canUpdate, - }; + return { + snapshots, + canLoadSnapshot: canUpdate, + }; + } + return { snapshots: [], showCommunityMessage: true }; } - afterModel(model: SnapshotsRouteModel, transition: Transition) { - // Don't redirect if we're already on details route - const toRoute = transition.to?.name; - if (model.snapshots.length === 1 && toRoute !== 'vault.cluster.recovery.snapshots.snapshot.details') { + redirect(model: SnapshotsRouteModel) { + if (model.snapshots.length === 1) { const snapshot_id = model.snapshots[0]; this.router.transitionTo('vault.cluster.recovery.snapshots.snapshot.manage', snapshot_id); } diff --git a/ui/app/routes/vault/cluster/recovery/snapshots/index.ts b/ui/app/routes/vault/cluster/recovery/snapshots/index.ts index e009c08924..7cd46f07ca 100644 --- a/ui/app/routes/vault/cluster/recovery/snapshots/index.ts +++ b/ui/app/routes/vault/cluster/recovery/snapshots/index.ts @@ -7,17 +7,13 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; import type RouterService from '@ember/routing/router-service'; -import type { SnapshotsRouteModel } from '../snapshots'; - export default class RecoverySnapshotsIndexRoute extends Route { @service declare readonly router: RouterService; - beforeModel() { - const parentModel = this.modelFor('vault.cluster.recovery.snapshots') as SnapshotsRouteModel; - - if (parentModel.snapshots.length === 1) { - const snapshot_id = parentModel.snapshots[0]; - this.router.transitionTo('vault.cluster.recovery.snapshots.snapshot.manage', snapshot_id); - } + // There is not a recovery.snapshots.index view because currently only one snapshot can be loaded at a time. + // Redirect to the parent route so we can reuse its logic and send users to "recovery.snapshots.snapshot.manage" + // if a snapshot is loaded. + redirect() { + this.router.transitionTo('vault.cluster.recovery.snapshots'); } } diff --git a/ui/app/templates/vault/cluster/recovery/error.hbs b/ui/app/templates/vault/cluster/recovery/error.hbs index b64675c169..7e6a3297ee 100644 --- a/ui/app/templates/vault/cluster/recovery/error.hbs +++ b/ui/app/templates/vault/cluster/recovery/error.hbs @@ -12,9 +12,9 @@ {{#if (eq this.model.message "raft storage is not in use")}} - - - + + + this.server.create('configuration', 'withRaft')); - return login(); }); - test('it renders empty state when no snapshots are loaded', async function (assert) { + test('enterprise: it renders empty state when raft storage is not in use', async function (assert) { this.server.get('/sys/storage/raft/snapshot-load', () => { - return new Response(404, { 'Content-Type': 'application/json' }, JSON.stringify({ errors: [] })); + return overrideResponse(400, JSON.stringify({ errors: ['raft storage is not in use'] })); + }); + await visit('/vault/recovery/snapshots'); + assert.strictEqual(currentURL(), '/vault/recovery/snapshots'); + assert.dom('header').exists('it renders header despite route throwing an error'); + assert.dom(GENERAL.emptyStateTitle).hasText('Raft storage required'); + assert + .dom(GENERAL.emptyStateMessage) + .hasText('Raft storage must be used in order to recover data from a snapshot.'); + assert.dom(GENERAL.emptyStateActions).hasText('Snapshot management'); + }); + + test('it renders promo for community versions', async function (assert) { + const version = this.owner.lookup('service:version'); + version.type = 'community'; + this.server.get('/sys/storage/raft/snapshot-load', () => { + // This assertion is intentionally setup to fail if a request is made to this endpoint + // because community versions should NOT request the snapshot-load endpoint + assert.true(false, 'it does not make a request to snapshot-load on CE versions'); }); await visit('/vault/recovery/snapshots'); - + assert + .dom(`${GENERAL.navLink('Secrets Recovery')} .hds-badge`) + .hasText('Enterprise', 'side nav link renders "Enterprise" badge'); assert.strictEqual(currentURL(), '/vault/recovery/snapshots'); - - assert.dom(GENERAL.emptyStateTitle).hasText('Upload a snapshot to get started'); - assert.dom(GENERAL.emptyStateActions).hasText('Upload snapshot'); + assert.dom(GENERAL.emptyStateTitle).hasText('Secrets Recovery is an enterprise feature'); + assert + .dom(GENERAL.emptyStateMessage) + .hasText( + 'Secrets Recovery allows you to restore accidentally deleted or lost secrets from a snapshot. The snapshots can be provided via upload or loaded from external storage.' + ); + assert.dom(GENERAL.emptyStateActions).hasText('Learn more about upgrading'); + assert.dom(GENERAL.badge('enterprise')).exists(); }); - test('it redirects to snapshot route when a snapshot is loaded', async function (assert) { - this.server.get('/sys/storage/raft/snapshot-load', () => { - return { data: { keys: ['1234'] } }; + module('enterprise: with raft configured', function (hooks) { + hooks.beforeEach(function () { + this.server.get('/sys/storage/raft/configuration', () => + this.server.create('configuration', 'withRaft') + ); }); - this.server.get('/sys/storage/raft/snapshot-load/1234', () => { - return { - data: { - status: 'ready', - expires_at: new Date(), - snapshot_id: '1234', - }, - }; + test('it renders empty state when no snapshots are loaded', async function (assert) { + this.server.get('/sys/storage/raft/snapshot-load', () => { + return new Response(404, { 'Content-Type': 'application/json' }, JSON.stringify({ errors: [] })); + }); + + await visit('/vault/recovery/snapshots'); + + assert.strictEqual(currentURL(), '/vault/recovery/snapshots'); + + assert.dom(GENERAL.emptyStateTitle).hasText('Upload a snapshot to get started'); + assert.dom(GENERAL.emptyStateActions).hasText('Upload snapshot'); }); - await visit('vault/recovery/snapshots'); + test('it redirects to snapshot route when a snapshot is loaded', async function (assert) { + this.server.get('/sys/storage/raft/snapshot-load', () => { + return { data: { keys: ['1234'] } }; + }); - assert.strictEqual(currentURL(), '/vault/recovery/snapshots/1234/manage'); - assert.strictEqual(currentRouteName(), 'vault.cluster.recovery.snapshots.snapshot.manage'); + this.server.get('/sys/storage/raft/snapshot-load/1234', () => { + return { + data: { + status: 'ready', + expires_at: addDays(new Date(), 3).toISOString(), + snapshot_id: '1234', + }, + }; + }); + + await click(GENERAL.navLink('Secrets Recovery')); + assert.strictEqual(currentURL(), '/vault/recovery/snapshots/1234/manage'); + assert.strictEqual(currentRouteName(), 'vault.cluster.recovery.snapshots.snapshot.manage'); + }); }); }); diff --git a/ui/tests/helpers/general-selectors.ts b/ui/tests/helpers/general-selectors.ts index 189365641f..625f301f1e 100644 --- a/ui/tests/helpers/general-selectors.ts +++ b/ui/tests/helpers/general-selectors.ts @@ -170,4 +170,5 @@ export const GENERAL = { /* ────── Misc ────── */ icon: (name: string) => (name ? `[data-test-icon="${name}"]` : '[data-test-icon]'), + badge: (name: string) => (name ? `[data-test-badge="${name}"]` : '[data-test-badge]'), }; diff --git a/ui/tests/integration/components/recovery/page/snapshot-manage-test.js b/ui/tests/integration/components/recovery/page/snapshot-manage-test.js index a0c37a3fb4..bec7b55cf9 100644 --- a/ui/tests/integration/components/recovery/page/snapshot-manage-test.js +++ b/ui/tests/integration/components/recovery/page/snapshot-manage-test.js @@ -12,10 +12,6 @@ import { setupMirage } from 'ember-cli-mirage/test-support'; import recoveryHandler from 'vault/mirage/handlers/recovery'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; -const SELECTORS = { - badge: (name) => `[data-test-badge="${name}"]`, -}; - module('Integration | Component | recovery/snapshots/snapshot-manage', function (hooks) { setupRenderingTest(hooks); setupMirage(hooks); @@ -45,7 +41,7 @@ module('Integration | Component | recovery/snapshots/snapshot-manage', function test('it displays loaded snapshot card', async function (assert) { await render(hbs``); - assert.dom(SELECTORS.badge('status')).hasText('Ready', 'status badge renders'); + assert.dom(GENERAL.badge('status')).hasText('Ready', 'status badge renders'); }); test('it displays namespace selector for root namespace', async function (assert) { diff --git a/ui/tests/integration/components/recovery/page/snapshots-test.js b/ui/tests/integration/components/recovery/page/snapshots-test.js index c75f7fa970..4c405c47ed 100644 --- a/ui/tests/integration/components/recovery/page/snapshots-test.js +++ b/ui/tests/integration/components/recovery/page/snapshots-test.js @@ -12,10 +12,6 @@ import { setupMirage } from 'ember-cli-mirage/test-support'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; -const SELECTORS = { - badge: (name) => `[data-test-badge="${name}"]`, -}; - module('Integration | Component | recovery/snapshots', function (hooks) { setupRenderingTest(hooks); setupMirage(hooks); @@ -25,12 +21,12 @@ module('Integration | Component | recovery/snapshots', function (hooks) { snapshots: [], canLoadSnapshot: false, }; - this.version = this.owner.lookup('service:version'); - this.version.type = 'enterprise'; + this.renderComponent = () => render(hbs``); }); test('it displays empty state in CE', async function (assert) { - this.version.type = 'community'; + this.model = { snapshots: [], showCommunityMessage: true }; + await render(hbs``); assert .dom(GENERAL.emptyStateTitle) @@ -45,14 +41,14 @@ module('Integration | Component | recovery/snapshots', function (hooks) { .dom(GENERAL.emptyStateActions) .hasText('Learn more about upgrading', 'CE empty state action renders'); - assert.dom(SELECTORS.badge('enterprise')).exists(); + assert.dom(GENERAL.badge('enterprise')).exists(); }); test('it displays empty state in non root namespace', async function (assert) { const nsService = this.owner.lookup('service:namespace'); nsService.setNamespace('test-ns'); - await render(hbs``); + await this.renderComponent(); assert .dom(GENERAL.emptyStateTitle) @@ -69,7 +65,7 @@ module('Integration | Component | recovery/snapshots', function (hooks) { }); test('it displays empty state when user cannot load snapshot', async function (assert) { - await render(hbs``); + await this.renderComponent(); assert.dom(GENERAL.emptyStateTitle).hasText('No snapshot available', 'empty state title renders'); assert .dom(GENERAL.emptyStateMessage) @@ -84,7 +80,7 @@ module('Integration | Component | recovery/snapshots', function (hooks) { test('it displays empty state when user can load snapshot', async function (assert) { this.model.canLoadSnapshot = true; - await render(hbs``); + await this.renderComponent(); assert .dom(GENERAL.emptyStateTitle) .hasText('Upload a snapshot to get started', 'empty state title renders');