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>
This commit is contained in:
Vault Automation 2025-09-23 19:12:01 -04:00 committed by GitHub
parent 3886debfa1
commit 0400a442c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 150 additions and 109 deletions

View File

@ -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}}
<EmptyState
@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."
>
<Hds::Button
@text="Learn more about upgrading"
@color="tertiary"
@icon="docs-link"
@iconPosition="trailing"
@href={{doc-link "/vault/docs/enterprise"}}
@isHrefExternal={{true}}
/>
</EmptyState>
{{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|}}
<EmptyState @title={{d.title}} @icon={{d.icon}} @message={{d.message}}>
@ -30,4 +45,4 @@ and a LIST view will be used then. }}
{{/if}}
</EmptyState>
{{/let}}
{{/unless}}
{{/if}}

View File

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

View File

@ -14,13 +14,11 @@
</div>
{{/if}}
<form {{on "submit" this.loadSnapshot}}>
<Hds::Form::Legend>
Choose how to provide the snapshot
</Hds::Form::Legend>
<div class="has-top-padding-s">
<Hds::Form::Radio::Field
name="data-center-radio"
<form {{on "submit" this.loadSnapshot}} class="has-top-margin-xl">
<Hds::Form::Radio::Group @name="snapshot-load-method" as |G|>
<G.Legend>Choose how to provide the snapshot</G.Legend>
<G.RadioField
name={{this.loadMethods.AUTOMATED}}
checked={{eq this.selectedLoadMethod this.loadMethods.AUTOMATED}}
disabled={{eq @model.configError.status 404}}
@value={{this.loadMethods.AUTOMATED}}
@ -43,11 +41,9 @@
<F.HelperText>
Provide the snapshot URL from a configured cloud storage
</F.HelperText>
</Hds::Form::Radio::Field>
</div>
<div class="has-top-padding-s has-bottom-padding-m">
<Hds::Form::Radio::Field
name="data-center-radio"
</G.RadioField>
<G.RadioField
name={{this.loadMethods.MANUAL}}
checked={{eq this.selectedLoadMethod this.loadMethods.MANUAL}}
@value={{this.loadMethods.MANUAL}}
{{on "change" this.selectLoadMethod}}
@ -58,9 +54,11 @@
<F.HelperText>
Upload a new snapshot to the disk
</F.HelperText>
</Hds::Form::Radio::Field>
</div>
<div class="has-left-padding-l">
</G.RadioField>
</Hds::Form::Radio::Group>
<div class="has-top-margin-l has-left-padding-l">
{{#if (eq this.selectedLoadMethod this.loadMethods.AUTOMATED)}}
<div class="has-bottom-padding-m">
@ -76,7 +74,13 @@
>
<F.Label>Snapshot configuration name</F.Label>
<F.HelperText>
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
<Hds::Link::Inline
@href={{doc-link
"/vault/api-docs/system/storage/raftautosnapshots#create-update-an-automated-snapshots-config"
}}
@isHrefExternal={{true}}
>automated snapshots config</Hds::Link::Inline>
endpoint.
</F.HelperText>
<F.Options>{{F.options}}</F.Options>
@ -94,7 +98,13 @@
>
<F.Label>Snapshot configuration name</F.Label>
<F.HelperText>
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
<Hds::Link::Inline
@href={{doc-link
"/vault/api-docs/system/storage/raftautosnapshots#create-update-an-automated-snapshots-config"
}}
@isHrefExternal={{true}}
>automated snapshots config</Hds::Link::Inline>
endpoint.
</F.HelperText>
{{#if this.configError}}
@ -123,7 +133,7 @@
</div>
{{else}}
<FileToArrayBuffer
class="hsa-left-padding-l"
class="has-left-padding-l"
@error={{this.fileError}}
@label="Please choose a snapshot file"
@onChange={{fn (mut this.file)}}

View File

@ -33,7 +33,7 @@
<hr class="has-background-gray-300" />
<Hds::Text::Display @tag="h3" class="has-top-padding-m">Recover or read data</Hds::Text::Display>
<Hds::Text::Display @tag="h3" class="has-top-padding-m has-bottom-margin-l">Recover or read data</Hds::Text::Display>
{{#if this.recoveryData}}
<Hds::Alert @type="inline" @color="success" class="has-top-margin-m has-bottom-margin-m" data-test-inline-alert as |A|>
<A.Title>Success</A.Title>
@ -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}}
<Hds::Form::SuperSelect::Single::Field
@onChange={{this.handleSelectMount}}

View File

@ -11,7 +11,7 @@ import type ApiService from 'vault/services/api';
import type Capabilities from 'vault/services/capabilities';
import type { ModelFrom } from 'vault/vault/route';
import type RouterService from '@ember/routing/router-service';
import type Transition from '@ember/routing/transition';
import type VersionService from 'vault/services/version';
export type SnapshotsRouteModel = ModelFrom<RecoverySnapshotsRoute>;
@ -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);
}

View File

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

View File

@ -12,9 +12,9 @@
{{#if (eq this.model.message "raft storage is not in use")}}
<Hds::ApplicationState as |A|>
<A.Header @title="Raft storage required" @icon="info" />
<A.Body @text="Raft storage must be used in order to recover data from a snapshot." />
<A.Footer as |F|>
<A.Header @title="Raft storage required" @icon="info" data-test-empty-state-title />
<A.Body @text="Raft storage must be used in order to recover data from a snapshot." data-test-empty-state-message />
<A.Footer data-test-empty-state-actions as |F|>
<F.LinkStandalone
@text="Snapshot management"
@icon="docs-link"

View File

@ -1,6 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
{{outlet}}

View File

@ -4,54 +4,99 @@
*/
import { module, test } from 'qunit';
import { visit, currentRouteName, currentURL } from '@ember/test-helpers';
import { visit, currentRouteName, currentURL, click } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { Response } from 'miragejs';
import { overrideResponse } from 'vault/tests/helpers/stubs';
import { addDays } from 'date-fns';
module('Acceptance | recovery | snapshots', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.server.get('/sys/storage/raft/configuration', () => 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');
});
});
});

View File

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

View File

@ -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`<Recovery::Page::Snapshots::SnapshotManage @model={{this.model}}/>`);
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) {

View File

@ -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`<Recovery::Page::Snapshots @model={{this.model}}/>`);
});
test('it displays empty state in CE', async function (assert) {
this.version.type = 'community';
this.model = { snapshots: [], showCommunityMessage: true };
await render(hbs`<Recovery::Page::Snapshots @model={{this.model}}/>`);
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`<Recovery::Page::Snapshots @model={{this.model}}/>`);
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`<Recovery::Page::Snapshots @model={{this.model}}/>`);
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`<Recovery::Page::Snapshots @model={{this.model}}/>`);
await this.renderComponent();
assert
.dom(GENERAL.emptyStateTitle)
.hasText('Upload a snapshot to get started', 'empty state title renders');