UI: pki auto-tidy views (#20685)

* UI: plumbing for pki tidy work (#20611)

* update tidy model

* Dynamic group on tidy based on version

* UI: VAULT-16261 PKI autotidy config view (#20641)

* UI: VAULT-16203 tidy status page (#20635)

* ui: pki tidy form (#20630)

* order routes to match tabs

* add tidy routes

* add tidy-status page component

* update routes rename edit to configure, remove manage

* add page component to route template

* add comment

* finish routing

* change to queryRecord, delete old tidy file

* remove findRecord

* fix serializer name

* tidy.index only needs controller empty state logic

* build form and page components

* update tidy model

* alphabetize!

* revert model changes

* finish adapter

* move form out of page folder in tests

* refactor to accommodate model changes from chelseas pr

* WIP tests

* reuse shared fields in model

* finish tests

* update model hook and breadcrumbs

* remove subtext for checkbox

* fix tests add ACME fields

* Update ui/app/adapters/pki/tidy.js

* Update ui/app/adapters/pki/tidy.js

* refactor intervalDuration using feedback suggested

* move errors to second line, inside conditional brackets

* add ternary operator to allByKey attr

* surface error message

* make polling request longer

* UI: VAULT-16368 pki tidy custom method (#20696)

* ui: adds empty state and updates modal (#20695)

* add empty state to status page

* update tidy modal

* conditionally change cancel transition route for auto tidy form

* teeny copy update

* organize tidy-status conditoionals

* a couple more template cleanups

* fix conditional, change to settings

* UI: VAULT-16367 VAULT-16378 Tidy acceptance tests + tidy toolbar cleanup (#20698)

* update copy

* move tidyRevokedCertIssuerAssociations up to applicable section

* add tidy info to readme

* update copy

* UI: Add tidy as a tab to the error route (#20723)

* small cleanup items

* fix prettier

* cancel polling when we leave tidy.index (status view)

* revert changes to declaration file

* remove space

---------

Co-authored-by: Chelsea Shaw <cshaw@hashicorp.com>
Co-authored-by: Chelsea Shaw <82459713+hashishaw@users.noreply.github.com>
Co-authored-by: Kianna <30884335+kiannaquach@users.noreply.github.com>
This commit is contained in:
claire bontempo 2023-05-23 16:05:15 -07:00 committed by GitHub
parent 527f4fe2ba
commit 4da72c45ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 1763 additions and 243 deletions

View File

@ -2,35 +2,49 @@
* Copyright (c) HashiCorp, Inc. * Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0 * SPDX-License-Identifier: MPL-2.0
*/ */
import { assert } from '@ember/debug';
import { encodePath } from 'vault/utils/path-encoding-helpers';
import ApplicationAdapter from '../application'; import ApplicationAdapter from '../application';
import { encodePath } from 'vault/utils/path-encoding-helpers';
export default class PkiTidyAdapter extends ApplicationAdapter { export default class PkiTidyAdapter extends ApplicationAdapter {
namespace = 'v1'; namespace = 'v1';
urlForCreateRecord(snapshot) { _baseUrl(backend) {
return `${this.buildURL()}/${encodePath(backend)}`;
}
// single tidy operations (manual) are always a new record
createRecord(store, type, snapshot) {
const { backend } = snapshot.record; const { backend } = snapshot.record;
const { tidyType } = snapshot.adapterOptions; const { tidyType } = snapshot.adapterOptions;
if (tidyType === 'auto') {
if (!backend) { throw new Error('Auto tidy type models are never new, please use findRecord');
throw new Error('Backend missing');
} }
const baseUrl = `${this.buildURL()}/${encodePath(backend)}`; const url = `${this._baseUrl(backend)}/tidy`;
return this.ajax(url, 'POST', { data: this.serialize(snapshot, tidyType) });
switch (tidyType) {
case 'manual-tidy':
return `${baseUrl}/tidy`;
case 'auto-tidy':
return `${baseUrl}/config/auto-tidy`;
default:
assert('type must be one of manual-tidy, auto-tidy');
}
} }
createRecord(store, type, snapshot) { // saving auto-tidy config POST requests will always update
const url = this.urlForCreateRecord(snapshot); updateRecord(store, type, snapshot) {
return this.ajax(url, 'POST', { data: this.serialize(snapshot) }); const backend = snapshot.record.id;
const { tidyType } = snapshot.adapterOptions;
if (tidyType === 'manual') {
throw new Error('Manual tidy type models are always new, please use createRecord');
}
const url = `${this._baseUrl(backend)}/config/auto-tidy`;
return this.ajax(url, 'POST', { data: this.serialize(snapshot, tidyType) });
}
findRecord(store, type, backend) {
// only auto-tidy will ever be read, no need to pass the type here
return this.ajax(`${this._baseUrl(backend)}/config/auto-tidy`, 'GET').then((resp) => {
return resp.data;
});
}
cancelTidy(backend) {
const url = `${this._baseUrl(backend)}`;
return this.ajax(`${url}/tidy-cancel`, 'POST');
} }
} }

View File

@ -4,9 +4,168 @@
*/ */
import Model, { attr } from '@ember-data/model'; import Model, { attr } from '@ember-data/model';
import { service } from '@ember/service';
import { withExpandedAttributes } from 'vault/decorators/model-expanded-attributes';
@withExpandedAttributes()
export default class PkiTidyModel extends Model { export default class PkiTidyModel extends Model {
@attr('boolean', { defaultValue: false }) tidyCertStore; // the backend mount is the model id, only one pki/tidy model will ever persist (the auto-tidy config)
@attr('boolean', { defaultValue: false }) tidyRevocationQueue; @service version;
@attr('string', { defaultValue: '72h' }) safetyBuffer;
@attr({
label: 'Tidy ACME enabled',
labelDisabled: 'Tidy ACME disabled',
mapToBoolean: 'tidyAcme',
helperTextDisabled: 'Tidying of ACME accounts, orders and authorizations is disabled',
helperTextEnabled:
'The amount of time that must pass after creation that an account with no orders is marked revoked, and the amount of time after being marked revoked or deactivated.',
detailsLabel: 'ACME account safety buffer',
formatTtl: true,
})
acmeAccountSafetyBuffer;
@attr('boolean', {
label: 'Tidy ACME',
defaultValue: false,
})
tidyAcme;
@attr('boolean', {
label: 'Automatic tidy enabled',
defaultValue: false,
})
enabled; // auto-tidy only
@attr({
label: 'Automatic tidy enabled',
labelDisabled: 'Automatic tidy disabled',
mapToBoolean: 'enabled',
helperTextEnabled:
'Sets the interval_duration between automatic tidy operations; note that this is from the end of one operation to the start of the next.',
helperTextDisabled: 'Automatic tidy operations will not run.',
detailsLabel: 'Automatic tidy duration',
formatTtl: true,
})
intervalDuration; // auto-tidy only
@attr('string', {
editType: 'ttl',
helperTextEnabled:
'Specifies a duration that issuers should be kept for, past their NotAfter validity period. Defaults to 365 days (8760 hours).',
hideToggle: true,
formatTtl: true,
})
issuerSafetyBuffer;
@attr('string', {
editType: 'ttl',
helperTextEnabled:
'Specifies the duration to pause between tidying individual certificates. This releases the revocation lock and allows other operations to continue while tidy is running.',
hideToggle: true,
formatTtl: true,
})
pauseDuration;
@attr('string', {
editType: 'ttl',
helperTextEnabled:
'Specifies a duration after which cross-cluster revocation requests will be removed as expired.',
hideToggle: true,
formatTtl: true,
})
revocationQueueSafetyBuffer; // enterprise only
@attr('string', {
editType: 'ttl',
helperTextEnabled:
'For a certificate to be expunged, the time must be after the expiration time of the certificate (according to the local clock) plus the safety buffer. Defaults to 72 hours.',
hideToggle: true,
formatTtl: true,
})
safetyBuffer;
@attr('boolean', { label: 'Tidy the certificate store' })
tidyCertStore;
@attr('boolean', {
label: 'Tidy cross-cluster revoked certificates',
subText: 'Remove expired, cross-cluster revocation entries.',
})
tidyCrossClusterRevokedCerts; // enterprise only
@attr('boolean', {
subText: 'Automatically remove expired issuers after the issuer safety buffer duration has elapsed.',
})
tidyExpiredIssuers;
@attr('boolean', {
label: 'Tidy legacy CA bundle',
subText:
'Backup any legacy CA/issuers bundle (from Vault versions earlier than 1.11) to config/ca_bundle.bak. Migration will only occur after issuer safety buffer has passed.',
})
tidyMoveLegacyCaBundle;
@attr('boolean', {
label: 'Tidy cross-cluster revocation requests',
})
tidyRevocationQueue; // enterprise only
@attr('boolean', {
label: 'Tidy revoked certificate issuer associations',
})
tidyRevokedCertIssuerAssociations;
@attr('boolean', {
label: 'Tidy revoked certificates',
subText: 'Remove all invalid and expired certificates from storage.',
})
tidyRevokedCerts;
get useOpenAPI() {
return true;
}
getHelpUrl(backend) {
return `/v1/${backend}/config/auto-tidy?help=1`;
}
get allGroups() {
const groups = [{ autoTidy: ['enabled', 'intervalDuration'] }, ...this.sharedFields];
return this._expandGroups(groups);
}
// shared between auto and manual tidy operations
get sharedFields() {
const groups = [
{
'Universal operations': [
'tidyCertStore',
'tidyRevokedCerts',
'tidyRevokedCertIssuerAssociations',
'safetyBuffer',
'pauseDuration',
],
},
{
'ACME operations': ['tidyAcme', 'acmeAccountSafetyBuffer'],
},
{
'Issuer operations': ['tidyExpiredIssuers', 'tidyMoveLegacyCaBundle', 'issuerSafetyBuffer'],
},
];
if (this.version.isEnterprise) {
groups.push({
'Cross-cluster operations': [
'tidyRevocationQueue',
'tidyCrossClusterRevokedCerts',
'revocationQueueSafetyBuffer',
],
});
}
return groups;
}
get formFieldGroups() {
return this._expandGroups(this.sharedFields);
}
} }

View File

@ -0,0 +1,17 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import ApplicationSerializer from '../application';
export default class PkiTidySerializer extends ApplicationSerializer {
serialize(snapshot, tidyType) {
const data = super.serialize(snapshot);
if (tidyType === 'manual') {
delete data?.enabled;
delete data?.intervalDuration;
}
return data;
}
}

View File

@ -38,6 +38,10 @@ const PKI_ENGINE_BACKEND = {
label: 'Certificates', label: 'Certificates',
link: 'certificates', link: 'certificates',
}, },
{
label: 'Tidy',
link: 'tidy',
},
{ {
label: 'Configuration', label: 'Configuration',
link: 'configuration', link: 'configuration',

View File

@ -32,6 +32,18 @@ If you couldn't tell from the documentation above, PKI is _complex_. As such, th
The `parsedCertificate` attribute is an object that houses all of the parsed certificate data returned by the [parse-pki-cert.js](../../app/utils/parse-pki-cert.js) util. The `parsedCertificate` attribute is an object that houses all of the parsed certificate data returned by the [parse-pki-cert.js](../../app/utils/parse-pki-cert.js) util.
- ### [pki/tidy](../../app/models/pki/tidy.js)
This model is used to manage [tidy](https://developer.hashicorp.com/vault/api-docs/secret/pki#tidy) operations in a few different contexts. All of the following endpoints share the same parameters _except_ `enabled` and `interval_duration` which are reserved for auto-tidy operations only.
> _`pki/tidy-status` does not use an Ember data model because it is read-only_
- `POST pki/tidy` - perform a single, manual tidy operation
- `POST pki/config/auto-tidy` - set configuration for automating the tidy process
- `GET pki/config/auto-tidy` - read auto-tidy configuration settings
The auto-tidy config is the only data that persists so `findRecord` and `updateRecord` in the `pki/tidy.js` [adapter](../../app/adapters/pki/tidy.js) only interact with the `/config/auto-tidy` endpoint. For each manual tidy operation, a new record is created so on `save()` the model uses the `createRecord` method which only ever uses the `/tidy` endpoint.
> _The following models more closely follow a CRUD pattern:_ > _The following models more closely follow a CRUD pattern:_
- ### [pki/issuer](../../app/models/pki/issuer.js) - ### [pki/issuer](../../app/models/pki/issuer.js)

View File

@ -14,9 +14,6 @@
</button> </button>
<div class="toolbar-separator"></div> <div class="toolbar-separator"></div>
{{/if}} {{/if}}
<ToolbarLink @route="configuration.tidy" data-test-tidy-toolbar>
Tidy
</ToolbarLink>
<ToolbarLink @route="configuration.edit"> <ToolbarLink @route="configuration.edit">
Edit configuration Edit configuration
</ToolbarLink> </ToolbarLink>

View File

@ -0,0 +1,18 @@
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3">
<Icon @name="pki" @size="24" class="has-text-grey-light" />
Configure automatic tidy
</h1>
</p.levelLeft>
</PageHeader>
<PkiTidyForm
@tidy={{@model}}
@tidyType="auto"
@onSave={{transition-to "vault.cluster.secrets.backend.pki.tidy.auto"}}
@onCancel={{transition-to (concat "vault.cluster.secrets.backend.pki.tidy" (if @model.enabled ".auto" ""))}}
/>

View File

@ -0,0 +1,40 @@
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3" data-test-header-title>
Automatic tidy configuration
</h1>
</p.levelLeft>
</PageHeader>
<Toolbar>
<ToolbarActions>
<LinkTo @route="tidy.auto.configure" class="toolbar-link" data-test-pki-edit-tidy-auto-link>
Edit auto-tidy
<Icon @name="chevron-right" />
</LinkTo>
</ToolbarActions>
</Toolbar>
<main>
{{#each @model.allGroups as |group|}}
{{#each-in group as |label fields|}}
{{#if (not-eq label "autoTidy")}}
<h2 class="title is-5 has-top-margin-l has-bottom-margin-xs" data-test-group-title={{label}}>
{{label}}
</h2>
{{/if}}
{{#each fields as |attr|}}
<InfoTableRow
@label={{or attr.options.detailsLabel attr.options.label (humanize (dasherize attr.name))}}
@value={{get @model attr.name}}
@formatTtl={{attr.options.formatTtl}}
data-test-row={{attr.name}}
/>
{{/each}}
{{/each-in}}
{{/each}}
</main>

View File

@ -1,83 +0,0 @@
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3">
<Icon @name="pki" @size="24" class="has-text-grey-light" />
Tidy
</h1>
</p.levelLeft>
</PageHeader>
<hr class="is-marginless has-background-gray-200" />
<p class="has-top-margin-m has-bottom-margin-l">Tidying cleans up the storage backend and/or CRL by removing certificates
that have expired and are past a certain buffer period beyond their expiration time.</p>
<MessageError @errorMessage={{this.errorBanner}} class="has-top-margin-s" />
<form class="has-bottom-margin-s" {{on "submit" (perform this.save)}}>
<div class="has-bottom-margin-s">
<Input
@type="checkbox"
@checked={{@tidy.tidyCertStore}}
id="tidy-certificate-store"
{{on "input" (fn (mut @tidy.tidyCertStore) (not @tidy.tidyCertStore))}}
data-test-tidy-cert-store-checkbox
/>
<label for="tidy-certificate-store" class="is-label" data-test-tidy-cert-store-label>
Tidy the certificate store
</label>
</div>
<div class="has-bottom-margin-s">
<Input
@type="checkbox"
@checked={{@tidy.tidyRevocationQueue}}
id="tidy-revocation-queue"
{{on "input" (fn (mut @tidy.tidyRevocationQueue) (not @tidy.tidyRevocationQueue))}}
data-test-tidy-revocation-queue-checkbox
/>
<label for="tidy-revocation-queue" class="is-label" data-test-tidy-revocation-queue-label>
Tidy the revocation list (CRL)
</label>
</div>
<TtlPicker
class="has-top-margin-l has-bottom-margin-l"
@initialValue={{@tidy.safetyBuffer}}
@onChange={{this.updateSafetyBuffer}}
@hideToggle={{true}}
@label="Safety buffer"
@helperTextEnabled="For a certificate to be expunged, the time must be after the expiration time of the certificate (according to the local
clock) plus the safety buffer. The default is 72 hours."
/>
<hr class="is-marginless has-background-gray-200" />
<div class="has-top-margin-m">
<button
type="submit"
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
disabled={{this.save.isRunning}}
data-test-pki-tidy-button
>
Tidy
</button>
<button
type="button"
class="button is-secondary"
disabled={{this.save.isRunning}}
{{on "click" this.cancel}}
data-test-pki-tidy-cancel
>
Cancel
</button>
{{#if this.invalidFormAlert}}
<div class="control">
<AlertInline @type="danger" @paddingTop={{true}} @message={{this.invalidFormAlert}} @mimicRefresh={{true}} />
</div>
{{/if}}
</div>
</form>

View File

@ -0,0 +1,18 @@
<PageHeader as |p|>
<p.top>
<Page::Breadcrumbs @breadcrumbs={{@breadcrumbs}} />
</p.top>
<p.levelLeft>
<h1 class="title is-3">
<Icon @name="pki" @size="24" class="has-text-grey-light" />
Manual tidy
</h1>
</p.levelLeft>
</PageHeader>
<PkiTidyForm
@tidy={{@model}}
@tidyType="manual"
@onSave={{transition-to "vault.cluster.secrets.backend.pki.tidy"}}
@onCancel={{transition-to "vault.cluster.secrets.backend.pki.tidy"}}
/>

View File

@ -0,0 +1,193 @@
<Toolbar>
<ToolbarActions>
<div class="toolbar-separator"></div>
{{#if @autoTidyConfig.enabled}}
<ToolbarLink @route="tidy.auto" data-test-pki-auto-tidy-config>
Auto-tidy configuration
</ToolbarLink>
<ToolbarLink @route="tidy.manual" data-test-pki-manual-tidy-config>
Perform manual tidy
</ToolbarLink>
{{else}}
<button
type="button"
class="toolbar-link"
{{on "click" (fn (mut this.tidyOptionsModal) true)}}
data-test-pki-tidy-options-modal
>
Tidy
<Icon @name="chevron-right" />
</button>
{{/if}}
</ToolbarActions>
</Toolbar>
{{#if this.hasTidyConfig}}
<Hds::Alert
@type="inline"
@color={{this.tidyStateAlertBanner.color}}
@icon={{this.tidyStateAlertBanner.icon}}
class="has-top-margin-m"
data-test-hds-alert
as |A|
>
<A.Title data-test-hds-alert-title>{{this.tidyStateAlertBanner.title}}</A.Title>
<A.Description data-test-hds-alert-description>{{this.tidyStateAlertBanner.message}}</A.Description>
{{#if this.tidyStateAlertBanner.shouldShowCancelTidy}}
<A.Button
@text="Cancel tidy"
@color="critical"
@icon={{if this.cancelTidy.isRunning "loading"}}
{{on "click" (fn (mut this.confirmCancelTidy) true)}}
data-test-cancel-tidy-action
/>
{{/if}}
{{#if @tidyStatus.responseTimestamp}}
<A.Description class="has-top-margin-xs" data-test-hds-alert-updated-at>
Updated
{{date-format @tidyStatus.responseTimestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}}
</A.Description>
{{/if}}
</Hds::Alert>
{{#each this.tidyStatusGeneralFields as |attr|}}
<InfoTableRow
@label={{humanize (dasherize attr)}}
@formatDate={{if
(includes attr (array "time_started" "time_finished" "last_auto_tidy_finished"))
"MMM dd, yyyy hh:mm:ss a"
}}
@value={{get @tidyStatus attr}}
@alwaysRender={{true}}
/>
{{/each}}
<h2 class="title is-4 has-bottom-margin-xs has-top-margin-l has-border-bottom-light has-bottom-padding-s">
{{if (eq this.tidyState "Running") "Current" "Last"}}
tidy settings
</h2>
{{#each this.tidyStatusConfigFields as |attr|}}
<InfoTableRow
@label={{humanize (dasherize attr)}}
@value={{get @tidyStatus attr}}
@formatTtl={{includes attr (array "safety_buffer" "issuer_safety_buffer")}}
@alwaysRender={{true}}
/>
{{/each}}
{{#if this.isEnterprise}}
{{#each this.crossClusterOperation as |attr|}}
<InfoTableRow
@label={{humanize (dasherize attr)}}
@value={{get @tidyStatus attr}}
@formatTtl={{eq attr "revocation_queue_safety_buffer"}}
@alwaysRender={{true}}
/>
{{/each}}
{{/if}}
{{else}}
<EmptyState
@title="Tidy status unavailable"
@message="After the next tidy operation has been performed, information about the current or most recent tidy operation will display here."
>
<button
type="button"
class="link"
{{on "click" (fn (mut this.tidyOptionsModal) true)}}
data-test-tidy-empty-state-configure
>
Tidy
</button>
</EmptyState>
{{/if}}
{{! TIDY OPTIONS MODAL }}
<Modal
@title="Tidy this mount"
@onClose={{fn (mut this.tidyOptionsModal) false}}
@isActive={{this.tidyOptionsModal}}
@showCloseButton={{true}}
>
<section aria-label="tidy-options-modal-content" class="modal-card-body">
<h3 class="title is-5">How tidying will work</h3>
<p class="has-text-grey has-bottom-padding-s">
Tidying cleans up the storage backend and/or CRL by removing certificates that have expired and are past a certain
buffer period beyond their expiration time.
<DocLink @path="/vault/docs/secrets/pki/considerations#automate-crl-building-and-tidying">
Documentation.
</DocLink>
</p>
<p class="has-text-grey">
<ol class="has-left-margin-m has-bottom-margin-s">
<li>Select a tidy operation:</li>
<ul class="bullet has-bottom-margin-xs">
<li><strong>Automatic tidy</strong>
periodically runs a tidy operation with saved configuration settings after waiting the specified interval
duration between tidies
</li>
<li><strong>Manual tidy</strong> runs a tidy operation once</li>
</ul>
<li>Configure the parameters that determine how to tidy and run the operation.</li>
</ol>
</p>
<div class="has-top-margin-l has-padding">
<img src={{img-path "~/pki-tidy.png"}} alt="tidy operation diagram" />
</div>
</section>
<footer aria-label="tidy-option-buttons" class="modal-card-foot modal-card-foot-outlined">
<button
type="button"
class="button is-primary"
{{on "click" (transition-to "vault.cluster.secrets.backend.pki.tidy.auto.configure")}}
data-test-tidy-modal-auto-button
>
Automatic tidy
</button>
<button
type="button"
class="button is-primary"
{{on "click" (transition-to "vault.cluster.secrets.backend.pki.tidy.manual")}}
data-test-tidy-modal-manual-button
>
Manual tidy
</button>
<button
type="button"
class="button is-secondary"
{{on "click" (fn (mut this.tidyOptionsModal) false)}}
data-test-tidy-modal-cancel-button
>
Cancel
</button>
</footer>
</Modal>
{{! CANCEL TIDY CONFIRMATION MODAL }}
{{#if this.confirmCancelTidy}}
<Modal
@type="warning"
@title="Cancel tidy?"
@onClose={{fn (mut this.confirmCancelTidy) false}}
@isActive={{this.confirmCancelTidy}}
@showCloseButton={{true}}
>
<section aria-label="confirm-cancel-modal-content" class="modal-card-body">
This will cancel the tidy at the next available checkpoint, which may process additional certificates between when the
operation was marked as cancelled and when the operation stopped.
<p class="has-top-margin-s">Click “Confirm” to cancel the running tidy operation.</p>
</section>
<footer aria-label="confirm-cancel-buttons" class="modal-card-foot modal-card-foot-outlined">
<button
type="button"
class="button is-primary"
{{on "click" (perform this.cancelTidy)}}
data-test-tidy-modal-cancel-button
>
Confirm
</button>
<button type="button" class="button is-secondary" {{on "click" (fn (mut this.confirmCancelTidy) false)}}>
Cancel
</button>
</footer>
</Modal>
{{/if}}

View File

@ -0,0 +1,157 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import errorMessage from 'vault/utils/error-message';
import type Store from '@ember-data/store';
import type SecretMountPath from 'vault/services/secret-mount-path';
import type FlashMessageService from 'vault/services/flash-messages';
import type VersionService from 'vault/services/version';
import type PkiTidyModel from 'vault/models/pki/tidy';
import type RouterService from '@ember/routing/router-service';
interface Args {
autoTidyConfig: PkiTidyModel;
tidyStatus: TidyStatusParams;
}
interface TidyStatusParams {
safety_buffer: number;
tidy_cert_store: boolean;
tidy_revoked_certs: boolean;
state: string;
error: string;
time_started: string | null;
time_finished: string | null;
message: string;
cert_store_deleted_count: number;
revoked_cert_deleted_count: number;
missing_issuer_cert_count: number;
tidy_expired_issuers: boolean;
issuer_safety_buffer: string;
tidy_move_legacy_ca_bundle: boolean;
tidy_revocation_queue: boolean;
revocation_queue_deleted_count: number;
tidy_cross_cluster_revoked_certs: boolean;
cross_revoked_cert_deleted_count: number;
revocation_queue_safety_buffer: string;
}
export default class PkiTidyStatusComponent extends Component<Args> {
@service declare readonly store: Store;
@service declare readonly secretMountPath: SecretMountPath;
@service declare readonly flashMessages: FlashMessageService;
@service declare readonly version: VersionService;
@service declare readonly router: RouterService;
@tracked tidyOptionsModal = false;
@tracked confirmCancelTidy = false;
tidyStatusGeneralFields = [
'time_started',
'time_finished',
'last_auto_tidy_finished',
'cert_store_deleted_count',
'missing_issuer_cert_count',
'revocation_queue_deleted_count',
];
tidyStatusConfigFields = [
'tidy_cert_store',
'tidy_revocation_queue',
'tidy_cross_cluster_revoked_certs',
'safety_buffer',
'pause_duration',
'tidy_expired_issuers',
'tidy_move_legacy_ca_bundle',
'issuer_safety_buffer',
];
crossClusterOperation = ['tidy_revocation_queue', 'revocation_queue_safety_buffer'];
get isEnterprise() {
return this.version.isEnterprise;
}
get tidyState() {
return this.args.tidyStatus?.state;
}
get hasTidyConfig() {
return !this.tidyStatusConfigFields.every(
(attr) => this.args.tidyStatus[attr as keyof TidyStatusParams] === null
);
}
get tidyStateAlertBanner() {
const tidyState = this.tidyState;
switch (tidyState) {
case 'Inactive':
return {
color: 'highlight',
title: 'Tidy is inactive',
message: this.args.tidyStatus?.message,
};
case 'Running':
return {
color: 'highlight',
title: 'Tidy in progress',
message: this.args.tidyStatus?.message,
shouldShowCancelTidy: true,
};
case 'Finished':
return {
color: 'success',
title: 'Tidy operation finished',
message: this.args.tidyStatus?.message,
};
case 'Error':
return {
color: 'warning',
title: 'Tidy operation failed',
message: this.args.tidyStatus?.error,
};
case 'Cancelling':
return {
color: 'warning',
title: 'Tidy operation cancelling',
icon: 'loading',
};
case 'Cancelled':
return {
color: 'warning',
title: 'Tidy operation cancelled',
message:
'Your tidy operation has been cancelled. If this was a mistake configure and run another tidy operation.',
};
default:
return {
color: 'warning',
title: 'Tidy status not found',
message: "The system reported no tidy status. It's recommended to perform a new tidy operation.",
};
}
}
@task
@waitFor
*cancelTidy() {
try {
const tidyAdapter = this.store.adapterFor('pki/tidy');
yield tidyAdapter.cancelTidy(this.secretMountPath.currentPath);
this.router.transitionTo('vault.cluster.secrets.backend.pki.tidy');
} catch (error) {
this.flashMessages.danger(errorMessage(error));
} finally {
this.confirmCancelTidy = false;
}
}
}

View File

@ -0,0 +1,80 @@
<hr class="is-marginless has-background-gray-200" />
<p class="has-top-margin-m has-bottom-margin-l">Tidying cleans up the storage backend and/or CRL by removing certificates
that have expired and are past a certain buffer period beyond their expiration time.
<DocLink @path="/vault/api-docs/secret/pki#{{if (eq @tidyType 'manual') 'tidy' 'configure-automatic-tidy'}}">Learn more</DocLink>
</p>
<MessageError @errorMessage={{this.errorBanner}} class="has-top-margin-s" />
<form class="has-bottom-margin-s" {{on "submit" (perform this.save)}} data-test-tidy-form={{@tidyType}}>
{{#if (and (eq @tidyType "auto") this.intervalDurationAttr)}}
{{#let this.intervalDurationAttr as |attr|}}
<TtlPicker
data-test-input={{attr.name}}
@onChange={{fn this.handleTtl attr}}
@label={{attr.options.label}}
@labelDisabled={{attr.options.labelDisabled}}
@helperTextDisabled={{attr.options.helperTextDisabled}}
@helperTextEnabled={{attr.options.helperTextEnabled}}
@initialEnabled={{get @tidy attr.options.mapToBoolean}}
@initialValue={{get @tidy attr.name}}
/>
{{/let}}
{{/if}}
{{#each @tidy.formFieldGroups as |fieldGroup|}}
{{#each-in fieldGroup as |group fields|}}
{{#if (or (eq @tidyType "manual") @tidy.enabled)}}
<h2 class="title is-size-5 has-border-bottom-light page-header" data-test-tidy-header={{group}}>
{{group}}
</h2>
{{#each fields as |attr|}}
{{#if (eq attr.name "acmeAccountSafetyBuffer")}}
<TtlPicker
data-test-input={{attr.name}}
@onChange={{fn this.handleTtl attr}}
@label={{attr.options.label}}
@labelDisabled={{attr.options.labelDisabled}}
@helperTextDisabled={{attr.options.helperTextDisabled}}
@helperTextEnabled={{attr.options.helperTextEnabled}}
@initialEnabled={{get @tidy attr.options.mapToBoolean}}
@initialValue={{get @tidy attr.name}}
/>
{{else}}
{{! tidyAcme is handled by the ttl above }}
{{#if (not-eq attr.name "tidyAcme")}}
<FormField @attr={{attr}} @model={{@tidy}} />
{{/if}}
{{/if}}
{{/each}}
{{/if}}
{{/each-in}}
{{/each}}
<hr class="is-marginless has-background-gray-200" />
<div class="has-top-margin-m">
<button
type="submit"
class="button is-primary {{if this.save.isRunning 'is-loading'}}"
disabled={{this.save.isRunning}}
data-test-pki-tidy-button
>
{{if (eq @tidyType "manual") "Perform tidy" "Save"}}
</button>
<button
type="button"
class="button is-secondary"
disabled={{this.save.isRunning}}
{{on "click" @onCancel}}
data-test-pki-tidy-cancel
>
Cancel
</button>
{{#if this.invalidFormAlert}}
<div class="control">
<AlertInline @type="danger" @paddingTop={{true}} @message={{this.invalidFormAlert}} @mimicRefresh={{true}} />
</div>
{{/if}}
</div>
</form>

View File

@ -4,18 +4,31 @@
*/ */
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import errorMessage from 'vault/utils/error-message';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { task } from 'ember-concurrency'; import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters'; import { waitFor } from '@ember/test-waiters';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import errorMessage from 'vault/utils/error-message';
import type PkiTidyModel from 'vault/models/pki/tidy';
import type RouterService from '@ember/routing/router-service'; import type RouterService from '@ember/routing/router-service';
import type PkiTidyModel from 'vault/models/pki/tidy';
import type { FormField, TtlEvent } from 'vault/app-types';
interface Args { interface Args {
tidy: PkiTidyModel; tidy: PkiTidyModel;
adapterOptions: object; tidyType: string;
onSave: CallableFunction;
onCancel: CallableFunction;
}
interface PkiTidyTtls {
intervalDuration: string;
acmeAccountSafetyBuffer: string;
}
interface PkiTidyBooleans {
enabled: boolean;
tidyAcme: boolean;
} }
export default class PkiTidyForm extends Component<Args> { export default class PkiTidyForm extends Component<Args> {
@ -24,13 +37,8 @@ export default class PkiTidyForm extends Component<Args> {
@tracked errorBanner = ''; @tracked errorBanner = '';
@tracked invalidFormAlert = ''; @tracked invalidFormAlert = '';
returnToConfiguration() { get intervalDurationAttr() {
this.router.transitionTo('vault.cluster.secrets.backend.pki.configuration.index'); return this.args.tidy?.allByKey.intervalDuration;
}
@action
updateSafetyBuffer({ goSafeTimeString }: { goSafeTimeString: string }) {
this.args.tidy.safetyBuffer = goSafeTimeString;
} }
@task @task
@ -38,8 +46,8 @@ export default class PkiTidyForm extends Component<Args> {
*save(event: Event) { *save(event: Event) {
event.preventDefault(); event.preventDefault();
try { try {
yield this.args.tidy.save({ adapterOptions: this.args.adapterOptions }); yield this.args.tidy.save({ adapterOptions: { tidyType: this.args.tidyType } });
this.returnToConfiguration(); this.args.onSave();
} catch (e) { } catch (e) {
this.errorBanner = errorMessage(e); this.errorBanner = errorMessage(e);
this.invalidFormAlert = 'There was an error submitting this form.'; this.invalidFormAlert = 'There was an error submitting this form.';
@ -47,7 +55,10 @@ export default class PkiTidyForm extends Component<Args> {
} }
@action @action
cancel() { handleTtl(attr: FormField, e: TtlEvent) {
this.returnToConfiguration(); const { enabled, goSafeTimeString } = e;
const ttlAttr = attr.name;
this.args.tidy[ttlAttr as keyof PkiTidyTtls] = goSafeTimeString;
this.args.tidy[attr.options.mapToBoolean as keyof PkiTidyBooleans] = enabled;
} }
} }

View File

@ -0,0 +1,37 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Ember from 'ember';
import Controller from '@ember/controller';
import { task, timeout } from 'ember-concurrency';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
const POLL_INTERVAL_MS = 5000;
export default class PkiTidyIndexController extends Controller {
@service store;
@service secretMountPath;
@tracked tidyStatus = null;
// this task is cancelled by resetController() upon leaving the pki.tidy.index route
@task
*pollTidyStatus() {
while (true) {
// when testing, the polling loop causes promises to never settle so acceptance tests hang
// to get around that, we just disable the poll in tests
if (Ember.testing) {
return;
}
yield timeout(POLL_INTERVAL_MS);
try {
const tidyStatusResponse = yield this.fetchTidyStatus();
this.tidyStatus = tidyStatusResponse;
} catch (e) {
// we want to keep polling here
}
}
}
}

View File

@ -7,12 +7,6 @@ import buildRoutes from 'ember-engines/routes';
export default buildRoutes(function () { export default buildRoutes(function () {
this.route('overview'); this.route('overview');
this.route('configuration', function () {
this.route('index', { path: '/' });
this.route('tidy');
this.route('create');
this.route('edit');
});
this.route('roles', function () { this.route('roles', function () {
this.route('index', { path: '/' }); this.route('index', { path: '/' });
this.route('create'); this.route('create');
@ -36,13 +30,6 @@ export default buildRoutes(function () {
this.route('rotate-root'); this.route('rotate-root');
}); });
}); });
this.route('certificates', function () {
this.route('index', { path: '/' });
this.route('certificate', { path: '/:serial' }, function () {
this.route('details');
this.route('edit');
});
});
this.route('keys', function () { this.route('keys', function () {
this.route('index', { path: '/' }); this.route('index', { path: '/' });
this.route('create'); this.route('create');
@ -52,4 +39,23 @@ export default buildRoutes(function () {
this.route('edit'); this.route('edit');
}); });
}); });
this.route('certificates', function () {
this.route('index', { path: '/' });
this.route('certificate', { path: '/:serial' }, function () {
this.route('details');
this.route('edit');
});
});
this.route('tidy', function () {
this.route('index', { path: '/' });
this.route('auto', function () {
this.route('configure');
});
this.route('manual');
});
this.route('configuration', function () {
this.route('index', { path: '/' });
this.route('create');
this.route('edit');
});
}); });

View File

@ -24,6 +24,7 @@ export default class PkiRoute extends Route {
signCsr: this.pathHelp.getNewModel('pki/sign-intermediate', mountPath), signCsr: this.pathHelp.getNewModel('pki/sign-intermediate', mountPath),
certGenerate: this.pathHelp.getNewModel('pki/certificate/generate', mountPath), certGenerate: this.pathHelp.getNewModel('pki/certificate/generate', mountPath),
certSign: this.pathHelp.getNewModel('pki/certificate/sign', mountPath), certSign: this.pathHelp.getNewModel('pki/certificate/sign', mountPath),
tidy: this.pathHelp.getNewModel('pki/tidy', mountPath),
}); });
} }
} }

View File

@ -21,6 +21,7 @@ export default class PkiRolesErrorRoute extends Route {
{ label: 'Issuers', route: 'issuers.index' }, { label: 'Issuers', route: 'issuers.index' },
{ label: 'Keys', route: 'keys.index' }, { label: 'Keys', route: 'keys.index' },
{ label: 'Certificates', route: 'certificates.index' }, { label: 'Certificates', route: 'certificates.index' },
{ label: 'Tidy', route: 'tidy.index' },
{ label: 'Configuration', route: 'configuration.index' }, { label: 'Configuration', route: 'configuration.index' },
]; ];
controller.title = this.secretMountPath.currentPath; controller.title = this.secretMountPath.currentPath;

View File

@ -0,0 +1,18 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { withConfig } from 'pki/decorators/check-config';
import { hash } from 'rsvp';
@withConfig()
export default class PkiTidyRoute extends Route {
@service store;
model() {
const engine = this.modelFor('application');
return hash({
hasConfig: this.shouldPromptConfig,
engine,
autoTidyConfig: this.store.findRecord('pki/tidy', engine.id),
});
}
}

View File

@ -0,0 +1,8 @@
import Route from '@ember/routing/route';
export default class PkiTidyAutoRoute extends Route {
model() {
const { autoTidyConfig } = this.modelFor('tidy');
return autoTidyConfig;
}
}

View File

@ -0,0 +1,23 @@
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
import { withConfirmLeave } from 'core/decorators/confirm-leave';
@withConfirmLeave()
export default class PkiTidyAutoConfigureRoute extends Route {
@service store;
@service secretMountPath;
// inherits model from tidy/auto
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
controller.breadcrumbs = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: this.secretMountPath.currentPath, route: 'overview' },
{ label: 'configuration', route: 'configuration.index' },
{ label: 'tidy', route: 'tidy' },
{ label: 'auto', route: 'tidy.auto' },
{ label: 'configure' },
];
}
}

View File

@ -0,0 +1,20 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class TidyAutoIndexRoute extends Route {
@service secretMountPath;
@service store;
// inherits model from tidy/auto
setupController(controller) {
super.setupController(...arguments);
controller.breadcrumbs = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: this.secretMountPath.currentPath, route: 'overview' },
{ label: 'tidy', route: 'tidy.index' },
{ label: 'auto' },
];
controller.title = this.secretMountPath.currentPath;
}
}

View File

@ -0,0 +1,47 @@
import Route from '@ember/routing/route';
import { PKI_DEFAULT_EMPTY_STATE_MSG } from '../overview';
import { hash } from 'rsvp';
import { inject as service } from '@ember/service';
import timestamp from 'core/utils/timestamp';
export default class PkiTidyIndexRoute extends Route {
@service store;
@service secretMountPath;
async fetchTidyStatus() {
const adapter = this.store.adapterFor('application');
const tidyStatusResponse = await adapter.ajax(
`/v1/${this.secretMountPath.currentPath}/tidy-status`,
'GET'
);
const responseTimestamp = timestamp.now();
tidyStatusResponse.data.responseTimestamp = responseTimestamp;
return tidyStatusResponse.data;
}
model() {
const { hasConfig, autoTidyConfig, engine } = this.modelFor('tidy');
return hash({
tidyStatus: this.fetchTidyStatus(),
hasConfig,
autoTidyConfig,
engine,
});
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
controller.notConfiguredMessage = PKI_DEFAULT_EMPTY_STATE_MSG;
controller.tidyStatus = resolvedModel.tidyStatus;
controller.fetchTidyStatus = this.fetchTidyStatus;
controller.pollTidyStatus.perform();
}
resetController(controller, isExiting) {
if (isExiting) {
controller.pollTidyStatus.cancelAll();
}
}
}

View File

@ -7,8 +7,8 @@ import Route from '@ember/routing/route';
import { inject as service } from '@ember/service'; import { inject as service } from '@ember/service';
import { withConfirmLeave } from 'core/decorators/confirm-leave'; import { withConfirmLeave } from 'core/decorators/confirm-leave';
@withConfirmLeave('model.tidy') @withConfirmLeave()
export default class PkiConfigurationTidyRoute extends Route { export default class PkiTidyManualRoute extends Route {
@service store; @service store;
@service secretMountPath; @service secretMountPath;
@ -22,7 +22,8 @@ export default class PkiConfigurationTidyRoute extends Route {
{ label: 'secrets', route: 'secrets', linkExternal: true }, { label: 'secrets', route: 'secrets', linkExternal: true },
{ label: this.secretMountPath.currentPath, route: 'overview' }, { label: this.secretMountPath.currentPath, route: 'overview' },
{ label: 'configuration', route: 'configuration.index' }, { label: 'configuration', route: 'configuration.index' },
{ label: 'tidy' }, { label: 'tidy', route: 'tidy' },
{ label: 'manual' },
]; ];
} }
} }

View File

@ -1 +0,0 @@
<Page::PkiTidyForm @breadcrumbs={{this.breadcrumbs}} @tidy={{this.model}} @adapterOptions={{hash tidyType="manual-tidy"}} />

View File

@ -0,0 +1 @@
{{outlet}}

View File

@ -0,0 +1 @@
{{outlet}}

View File

@ -0,0 +1 @@
<Page::PkiTidyAutoConfigure @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} />

View File

@ -0,0 +1 @@
<Page::PkiTidyAutoSettings @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} />

View File

@ -0,0 +1,20 @@
<SecretListHeader
@model={{this.model.engine}}
@backendCrumb={{hash
label=this.model.engine.id
text=this.model.engine.id
path="vault.cluster.secrets.backend.list-root"
model=this.model.engine.id
}}
@isEngine={{true}}
/>
{{#if this.model.hasConfig}}
<Page::PkiTidyStatus @autoTidyConfig={{this.model.autoTidyConfig}} @tidyStatus={{this.tidyStatus}} />
{{else}}
<Toolbar />
<EmptyState @title="PKI not configured" @message={{this.notConfiguredMessage}}>
<LinkTo @route="configuration.create">
Configure PKI
</LinkTo>
</EmptyState>
{{/if}}

View File

@ -0,0 +1 @@
<Page::PkiTidyManual @model={{this.model}} @breadcrumbs={{this.breadcrumbs}} />

View File

@ -8,6 +8,7 @@
"ember-cli-babel": "*", "ember-cli-babel": "*",
"ember-cli-htmlbars": "*", "ember-cli-htmlbars": "*",
"ember-cli-typescript": "*", "ember-cli-typescript": "*",
"@hashicorp/design-system-components": "*",
"@types/ember": "latest", "@types/ember": "latest",
"@types/ember-data": "latest", "@types/ember-data": "latest",
"@types/ember-data__adapter": "latest", "@types/ember-data__adapter": "latest",

BIN
ui/public/images/pki-tidy.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -42,7 +42,7 @@ module('Acceptance | pki workflow', function (hooks) {
}); });
test('empty state messages are correct when PKI not configured', async function (assert) { test('empty state messages are correct when PKI not configured', async function (assert) {
assert.expect(17); assert.expect(21);
const assertEmptyState = (assert, resource) => { const assertEmptyState = (assert, resource) => {
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/${resource}`); assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/${resource}`);
assert assert
@ -71,9 +71,10 @@ module('Acceptance | pki workflow', function (hooks) {
await click(SELECTORS.certsTab); await click(SELECTORS.certsTab);
assertEmptyState(assert, 'certificates'); assertEmptyState(assert, 'certificates');
await click(SELECTORS.keysTab); await click(SELECTORS.keysTab);
assertEmptyState(assert, 'keys'); assertEmptyState(assert, 'keys');
await click(SELECTORS.tidyTab);
assertEmptyState(assert, 'tidy');
}); });
module('roles', function (hooks) { module('roles', function (hooks) {
@ -443,27 +444,6 @@ module('Acceptance | pki workflow', function (hooks) {
.dom('[data-test-input="commonName"]') .dom('[data-test-input="commonName"]')
.hasValue('Hashicorp Test', 'form prefilled with parent issuer cn'); .hasValue('Hashicorp Test', 'form prefilled with parent issuer cn');
}); });
test('it navigates to the tidy page from configuration toolbar', async function (assert) {
await authPage.login(this.pkiAdminToken);
await visit(`/vault/secrets/${this.mountPath}/pki/configuration`);
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/configuration`);
await click(SELECTORS.configuration.tidyToolbar);
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/configuration/tidy`);
});
test('it returns to the configuration page after submit', async function (assert) {
await authPage.login(this.pkiAdminToken);
await visit(`/vault/secrets/${this.mountPath}/pki/configuration`);
await click(SELECTORS.configuration.tidyToolbar);
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/configuration/tidy`);
await click(SELECTORS.configuration.tidyCertStoreCheckbox);
await click(SELECTORS.configuration.tidyRevocationCheckbox);
await fillIn(SELECTORS.configuration.safetyBufferInput, '100');
await fillIn(SELECTORS.configuration.safetyBufferInputDropdown, 'd');
await click(SELECTORS.configuration.tidySave);
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/configuration`);
});
}); });
module('rotate', function (hooks) { module('rotate', function (hooks) {

View File

@ -0,0 +1,181 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { click, currentRouteName, fillIn, visit } from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { v4 as uuidv4 } from 'uuid';
import authPage from 'vault/tests/pages/auth';
import logout from 'vault/tests/pages/logout';
import enablePage from 'vault/tests/pages/settings/mount-secret-backend';
import { runCommands } from 'vault/tests/helpers/pki/pki-run-commands';
import { SELECTORS } from 'vault/tests/helpers/pki/page/pki-tidy';
module('Acceptance | pki tidy', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function () {
await authPage.login();
// Setup PKI engine
const mountPath = `pki-workflow-${uuidv4()}`;
await enablePage.enable('pki', mountPath);
this.mountPath = mountPath;
await runCommands([
`write ${this.mountPath}/root/generate/internal common_name="Hashicorp Test" name="Hashicorp Test"`,
]);
await logout.visit();
});
hooks.afterEach(async function () {
await logout.visit();
await authPage.login();
// Cleanup engine
await runCommands([`delete sys/mounts/${this.mountPath}`]);
await logout.visit();
});
test('it configures a manual tidy operation and shows its details and tidy states', async function (assert) {
await authPage.login(this.pkiAdminToken);
await visit(`/vault/secrets/${this.mountPath}/pki/tidy`);
await click(SELECTORS.tidyEmptyStateConfigure);
assert.dom(SELECTORS.tidyConfigureModal.configureTidyModal).exists('Configure tidy modal exists');
assert.dom(SELECTORS.tidyConfigureModal.tidyModalAutoButton).exists('Configure auto tidy button exists');
assert
.dom(SELECTORS.tidyConfigureModal.tidyModalManualButton)
.exists('Configure manual tidy button exists');
await click(SELECTORS.tidyConfigureModal.tidyModalManualButton);
assert.dom(SELECTORS.tidyForm.tidyFormName('manual')).exists('Manual tidy form exists');
await click(SELECTORS.tidyForm.inputByAttr('tidyCertStore'));
await fillIn(SELECTORS.tidyForm.tidyPauseDuration, '10');
await click(SELECTORS.tidyForm.tidySave);
await click(SELECTORS.cancelTidyAction);
assert.dom(SELECTORS.cancelTidyModalBackground).exists('Confirm cancel tidy modal exits');
await click(SELECTORS.tidyConfigureModal.tidyModalCancelButton);
// we can't properly test the background refresh fetching of tidy status in testing
this.server.get(`${this.mountPath}/tidy-status`, () => {
return {
request_id: 'dba2d42d-1a6e-1551-80f8-4ddb364ede4b',
lease_id: '',
renewable: false,
lease_duration: 0,
data: {
acme_account_deleted_count: 0,
acme_account_revoked_count: 0,
acme_account_safety_buffer: 2592000,
acme_orders_deleted_count: 0,
cert_store_deleted_count: 0,
cross_revoked_cert_deleted_count: 0,
current_cert_store_count: null,
current_revoked_cert_count: null,
error: null,
internal_backend_uuid: '964a41f7-a159-53aa-d62e-fc1914e4a7e1',
issuer_safety_buffer: 31536000,
last_auto_tidy_finished: '2023-05-19T10:27:11.721825-07:00',
message: 'Tidying certificate store: checking entry 0 of 1',
missing_issuer_cert_count: 0,
pause_duration: '1m40s',
revocation_queue_deleted_count: 0,
revocation_queue_safety_buffer: 36000,
revoked_cert_deleted_count: 0,
safety_buffer: 2073600,
state: 'Cancelled',
tidy_acme: false,
tidy_cert_store: true,
tidy_cross_cluster_revoked_certs: false,
tidy_expired_issuers: false,
tidy_move_legacy_ca_bundle: false,
tidy_revocation_queue: false,
tidy_revoked_cert_issuer_associations: false,
tidy_revoked_certs: false,
time_finished: '2023-05-19T10:28:51.733092-07:00',
time_started: '2023-05-19T10:27:11.721846-07:00',
total_acme_account_count: 0,
},
wrap_info: null,
warnings: null,
auth: null,
};
});
await visit(`/vault/secrets/${this.mountPath}/pki/configuration`);
await visit(`/vault/secrets/${this.mountPath}/pki/tidy`);
assert.dom(SELECTORS.hdsAlertTitle).hasText('Tidy operation cancelled');
assert
.dom(SELECTORS.hdsAlertDescription)
.hasText(
'Your tidy operation has been cancelled. If this was a mistake configure and run another tidy operation.'
);
assert.dom(SELECTORS.alertUpdatedAt).exists();
});
test('it configures an auto tidy operation and shows its details', async function (assert) {
await authPage.login(this.pkiAdminToken);
await visit(`/vault/secrets/${this.mountPath}/pki/tidy`);
await click(SELECTORS.tidyEmptyStateConfigure);
assert.dom(SELECTORS.tidyConfigureModal.configureTidyModal).exists('Configure tidy modal exists');
assert.dom(SELECTORS.tidyConfigureModal.tidyModalAutoButton).exists('Configure auto tidy button exists');
assert
.dom(SELECTORS.tidyConfigureModal.tidyModalManualButton)
.exists('Configure manual tidy button exists');
await click(SELECTORS.tidyConfigureModal.tidyModalAutoButton);
assert.dom(SELECTORS.tidyForm.tidyFormName('auto')).exists('Auto tidy form exists');
await click(SELECTORS.tidyForm.tidyCancel);
assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.backend.pki.tidy.index');
await click(SELECTORS.tidyEmptyStateConfigure);
await click(SELECTORS.tidyConfigureModal.tidyModalAutoButton);
assert.dom(SELECTORS.tidyForm.tidyFormName('auto')).exists('Auto tidy form exists');
await click(SELECTORS.tidyForm.toggleLabel('Automatic tidy disabled'));
assert
.dom(SELECTORS.tidyForm.tidySectionHeader('ACME operations'))
.exists('Auto tidy form enabled shows ACME operations field');
await click(SELECTORS.tidyForm.inputByAttr('tidyCertStore'));
await click(SELECTORS.tidyForm.tidySave);
assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.backend.pki.tidy.auto.index');
await click(SELECTORS.tidyForm.editAutoTidyButton);
assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.backend.pki.tidy.auto.configure');
await click(SELECTORS.tidyForm.inputByAttr('tidyRevokedCerts'));
await click(SELECTORS.tidyForm.tidySave);
assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.backend.pki.tidy.auto.index');
});
test('it opens a tidy modal when the user clicks on the tidy toolbar action', async function (assert) {
await authPage.login(this.pkiAdminToken);
await visit(`/vault/secrets/${this.mountPath}/pki/tidy`);
await click(SELECTORS.tidyConfigureModal.tidyOptionsModal);
assert.dom(SELECTORS.tidyConfigureModal.configureTidyModal).exists('Configure tidy modal exists');
assert.dom(SELECTORS.tidyConfigureModal.tidyModalAutoButton).exists('Configure auto tidy button exists');
assert
.dom(SELECTORS.tidyConfigureModal.tidyModalManualButton)
.exists('Configure manual tidy button exists');
await click(SELECTORS.tidyConfigureModal.tidyModalCancelButton);
assert.dom(SELECTORS.tidyEmptyState).exists();
});
test('it should show correct toolbar action depending on whether auto tidy is enabled', async function (assert) {
await authPage.login(this.pkiAdminToken);
await visit(`/vault/secrets/${this.mountPath}/pki/tidy`);
assert
.dom(SELECTORS.tidyConfigureModal.tidyOptionsModal)
.exists('Configure tidy modal options button exists');
await click(SELECTORS.tidyConfigureModal.tidyOptionsModal);
assert.dom(SELECTORS.tidyConfigureModal.configureTidyModal).exists('Configure tidy modal exists');
await click(SELECTORS.tidyConfigureModal.tidyOptionsModal);
await click(SELECTORS.tidyConfigureModal.tidyModalAutoButton);
await click(SELECTORS.tidyForm.toggleLabel('Automatic tidy disabled'));
await click(SELECTORS.tidyForm.inputByAttr('tidyCertStore'));
await click(SELECTORS.tidyForm.inputByAttr('tidyRevokedCerts'));
await click(SELECTORS.tidyForm.tidySave);
await visit(`/vault/secrets/${this.mountPath}/pki/tidy`);
assert
.dom(SELECTORS.manualTidyToolbar)
.exists('Manual tidy toolbar action exists if auto tidy is configured');
assert
.dom(SELECTORS.autoTidyToolbar)
.exists('Auto tidy toolbar action exists if auto tidy is configured');
});
});

View File

@ -4,14 +4,15 @@
*/ */
export const SELECTORS = { export const SELECTORS = {
tidyCertStoreLabel: '[data-test-tidy-cert-store-label]', tidyFormName: (attr) => `[data-test-tidy-form="${attr}"]`,
tidyRevocationList: '[data-test-tidy-revocation-queue-label]', inputByAttr: (attr) => `[data-test-input="${attr}"]`,
safetyBufferTTL: '[data-test-ttl-inputs]', toggleInput: (attr) => `[data-test-input="${attr}"] input`,
tidyCertStoreCheckbox: '[data-test-tidy-cert-store-checkbox]', intervalDuration: '[data-test-ttl-value="Automatic tidy enabled"]',
tidyRevocationCheckbox: '[data-test-tidy-revocation-queue-checkbox]', acmeAccountSafetyBuffer: '[data-test-ttl-value="Tidy ACME enabled"]',
safetyBufferInput: '[data-test-ttl-value="Safety buffer"]', toggleLabel: (label) => `[data-test-toggle-label="${label}"]`,
safetyBufferInputDropdown: '[data-test-select="ttl-unit"]', tidySectionHeader: (header) => `[data-test-tidy-header="${header}"]`,
tidyToolbar: '[data-test-tidy-toolbar]',
tidySave: '[data-test-pki-tidy-button]', tidySave: '[data-test-pki-tidy-button]',
tidyCancel: '[data-test-pki-tidy-cancel]', tidyCancel: '[data-test-pki-tidy-cancel]',
tidyPauseDuration: '[data-test-ttl-value="Pause duration"]',
editAutoTidyButton: '[data-test-pki-edit-tidy-auto-link]',
}; };

View File

@ -0,0 +1,30 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import { SELECTORS as TIDY_FORM } from './pki-tidy-form';
export const SELECTORS = {
hdsAlertTitle: '[data-test-hds-alert-title]',
hdsAlertDescription: '[data-test-hds-alert-description]',
alertUpdatedAt: '[data-test-hds-alert-updated-at]',
cancelTidyAction: '[data-test-cancel-tidy-action]',
hdsAlertButtonText: '[data-test-cancel-tidy-action] .hds-button__text',
timeStartedRow: '[data-test-value-div="Time started"]',
timeFinishedRow: '[data-test-value-div="Time finished"]',
cancelTidyModalBackground: '[data-test-modal-background="Cancel tidy?"]',
tidyEmptyStateConfigure: '[data-test-tidy-empty-state-configure]',
manualTidyToolbar: '[data-test-pki-manual-tidy-config]',
autoTidyToolbar: '[data-test-pki-auto-tidy-config]',
tidyConfigureModal: {
configureTidyModal: '[data-test-modal-background="Tidy this mount"]',
tidyModalAutoButton: '[data-test-tidy-modal-auto-button]',
tidyModalManualButton: '[data-test-tidy-modal-manual-button]',
tidyModalCancelButton: '[data-test-tidy-modal-cancel-button]',
tidyOptionsModal: '[data-test-pki-tidy-options-modal]',
},
tidyEmptyState: '[data-test-component="empty-state"]',
tidyForm: {
...TIDY_FORM,
},
};

View File

@ -28,6 +28,7 @@ export const SELECTORS = {
issuersTab: '[data-test-secret-list-tab="Issuers"]', issuersTab: '[data-test-secret-list-tab="Issuers"]',
certsTab: '[data-test-secret-list-tab="Certificates"]', certsTab: '[data-test-secret-list-tab="Certificates"]',
keysTab: '[data-test-secret-list-tab="Keys"]', keysTab: '[data-test-secret-list-tab="Keys"]',
tidyTab: '[data-test-secret-list-tab="Tidy"]',
configTab: '[data-test-secret-list-tab="Configuration"]', configTab: '[data-test-secret-list-tab="Configuration"]',
// ROLES // ROLES
deleteRoleButton: '[data-test-pki-role-delete]', deleteRoleButton: '[data-test-pki-role-delete]',

View File

@ -0,0 +1,67 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupEngine } from 'ember-engines/test-support';
module('Integration | Component | page/pki-tidy-auto-settings', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'pki');
hooks.beforeEach(function () {
const backend = 'pki-auto-tidy';
this.backend = backend;
this.context = { owner: this.engine };
this.store = this.owner.lookup('service:store');
this.breadcrumbs = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: backend, route: 'overview' },
{ label: 'tidy', route: 'tidy.index' },
{ label: 'auto' },
];
});
test('it renders', async function (assert) {
const model = this.store.createRecord('pki/tidy', {
backend: this.backend,
tidyType: 'auto',
enabled: false,
intervalDuration: '2d',
tidyCertStore: false,
tidyExpiredIssuers: true,
});
this.set('model', model);
await render(
hbs`<Page::PkiTidyAutoSettings @breadcrumbs={{this.breadcrumbs}} @model={{this.model}} />`,
this.context
);
assert.dom('[data-test-breadcrumbs] li').exists({ count: 4 }, 'an item exists for each breadcrumb');
assert.dom('[data-test-header-title]').hasText('Automatic tidy configuration', 'title is correct');
assert
.dom('[data-test-pki-edit-tidy-auto-link]')
.hasText('Edit auto-tidy', 'toolbar edit link has correct text');
assert.dom('[data-test-row="enabled"] [data-test-label-div]').hasText('Automatic tidy enabled');
assert.dom('[data-test-row="intervalDuration"] [data-test-label-div]').hasText('Automatic tidy duration');
// Universal operations
assert.dom('[data-test-group-title="Universal operations"]').hasText('Universal operations');
assert
.dom('[data-test-value-div="Tidy the certificate store"]')
.exists('Renders universal field when value exists');
assert
.dom('[data-test-value-div="Tidy revoked certificates"]')
.doesNotExist('Does not render universal field when value null');
// Issuer operations
assert.dom('[data-test-group-title="Issuer operations"]').hasText('Issuer operations');
assert
.dom('[data-test-value-div="Tidy expired issuers"]')
.exists('Renders issuer op field when value exists');
assert
.dom('[data-test-value-div="Tidy legacy CA bundle"]')
.doesNotExist('Does not render issuer op field when value null');
});
});

View File

@ -1,58 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { click, render, fillIn } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { SELECTORS } from 'vault/tests/helpers/pki/page/pki-tidy-form';
module('Integration | Component | pki | Page::PkiTidyForm', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'pki');
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.secretMountPath = this.owner.lookup('service:secret-mount-path');
this.secretMountPath.currentPath = 'pki-test';
this.tidy = this.store.createRecord('pki/tidy', { backend: 'pki-test' });
this.breadcrumbs = [
{ label: 'secrets', route: 'secrets', linkExternal: true },
{ label: 'pki-test', route: 'overview' },
{ label: 'configuration', route: 'configuration.index' },
{ label: 'tidy' },
];
});
test('it should render tidy fields', async function (assert) {
await render(hbs`<Page::PkiTidyForm @tidy={{this.tidy}} @breadcrumbs={{this.breadcrumbs}} />`, {
owner: this.engine,
});
assert.dom(SELECTORS.tidyCertStoreLabel).hasText('Tidy the certificate store');
assert.dom(SELECTORS.tidyRevocationList).hasText('Tidy the revocation list (CRL)');
assert.dom(SELECTORS.safetyBufferTTL).exists();
assert.dom(SELECTORS.safetyBufferInput).hasValue('3');
assert.dom('[data-test-select="ttl-unit"]').hasValue('d');
});
test('it should change the attributes on the model', async function (assert) {
await render(hbs`<Page::PkiTidyForm @tidy={{this.tidy}} @breadcrumbs={{this.breadcrumbs}} />`, {
owner: this.engine,
});
await click(SELECTORS.tidyCertStoreCheckbox);
await click(SELECTORS.tidyRevocationCheckbox);
await fillIn(SELECTORS.safetyBufferInput, '5');
assert.true(this.tidy.tidyCertStore);
assert.true(this.tidy.tidyRevocationQueue);
assert.dom(SELECTORS.safetyBufferInput).hasValue('5');
assert.dom('[data-test-select="ttl-unit"]').hasValue('d');
assert.strictEqual(this.tidy.safetyBuffer, '120h');
});
});

View File

@ -0,0 +1,114 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { SELECTORS } from 'vault/tests/helpers/pki/page/pki-tidy';
module('Integration | Component | Page::PkiTidyStatus', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'pki');
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.secretMountPath = this.owner.lookup('service:secret-mount-path');
this.secretMountPath.currentPath = 'pki-test';
this.store.createRecord('pki/issuer', { issuerId: 'abcd-efgh' });
this.store.createRecord('pki/tidy', { backend: this.secretMountPath.currentPath, tidyType: 'auto' });
this.autoTidyConfig = this.store.peekAll('pki/tidy');
this.tidyStatus = {
acme_account_deleted_count: 0,
acme_account_revoked_count: 0,
acme_account_safety_buffer: 2592000,
acme_orders_deleted_count: 0,
cert_store_deleted_count: 0,
cross_revoked_cert_deleted_count: 0,
current_cert_store_count: null,
current_revoked_cert_count: null,
error: null,
internal_backend_uuid: '9d3bd186-0fdd-9ca4-f298-2e180536b743',
issuer_safety_buffer: 31536000,
last_auto_tidy_finished: '2023-05-18T13:27:36.390785-07:00',
message: 'Tidying certificate store: checking entry 0 of 1',
missing_issuer_cert_count: 0,
pause_duration: '15s',
revocation_queue_deleted_count: 0,
revocation_queue_safety_buffer: 36000,
revoked_cert_deleted_count: 0,
safety_buffer: 2073600,
state: 'Running',
tidy_acme: false,
tidy_cert_store: true,
tidy_cross_cluster_revoked_certs: false,
tidy_expired_issuers: false,
tidy_move_legacy_ca_bundle: false,
time_started: '2023-05-18T13:27:36.390959-07:00',
};
this.engineId = 'pki';
});
test('shows the correct titles for the alert banner based on states', async function (assert) {
await render(
hbs`<Page::PkiTidyStatus @autoTidyConfig={{this.autoTidyConfig}} @tidyStatus={{this.tidyStatus}} /> <div id="modal-wormhole"></div>,`,
{ owner: this.engine }
);
// running state
assert.dom(SELECTORS.hdsAlertTitle).hasText('Tidy in progress');
assert.dom(SELECTORS.cancelTidyAction).exists();
assert.dom(SELECTORS.hdsAlertButtonText).hasText('Cancel tidy');
// inactive state
this.tidyStatus.state = 'Inactive';
await render(
hbs`<Page::PkiTidyStatus @autoTidyConfig={{this.autoTidyConfig}} @tidyStatus={{this.tidyStatus}} /> <div id="modal-wormhole"></div>,`,
{ owner: this.engine }
);
assert.dom(SELECTORS.hdsAlertTitle).hasText('Tidy is inactive');
// finished state
this.tidyStatus.state = 'Finished';
await render(
hbs`<Page::PkiTidyStatus @autoTidyConfig={{this.autoTidyConfig}} @tidyStatus={{this.tidyStatus}} /> <div id="modal-wormhole"></div>,`,
{ owner: this.engine }
);
assert.dom(SELECTORS.hdsAlertTitle).hasText('Tidy operation finished');
// error state
this.tidyStatus.state = 'Error';
await render(
hbs`<Page::PkiTidyStatus @autoTidyConfig={{this.autoTidyConfig}} @tidyStatus={{this.tidyStatus}} /> <div id="modal-wormhole"></div>,`,
{ owner: this.engine }
);
assert.dom(SELECTORS.hdsAlertTitle).hasText('Tidy operation failed');
// cancelling state
this.tidyStatus.state = 'Cancelling';
await render(
hbs`<Page::PkiTidyStatus @autoTidyConfig={{this.autoTidyConfig}} @tidyStatus={{this.tidyStatus}} /> <div id="modal-wormhole"></div>,`,
{ owner: this.engine }
);
assert.dom(SELECTORS.hdsAlertTitle).hasText('Tidy operation cancelling');
// cancelled state
this.tidyStatus.state = 'Cancelled';
await render(
hbs`<Page::PkiTidyStatus @autoTidyConfig={{this.autoTidyConfig}} @tidyStatus={{this.tidyStatus}} /> <div id="modal-wormhole"></div>,`,
{ owner: this.engine }
);
assert.dom(SELECTORS.hdsAlertTitle).hasText('Tidy operation cancelled');
});
test('shows the fields even if the data returns null values', async function (assert) {
this.tidyStatus.time_started = null;
this.tidyStatus.time_finished = null;
await render(
hbs`<Page::PkiTidyStatus @autoTidyConfig={{this.autoTidyConfig}} @tidyStatus={{this.tidyStatus}} /> <div id="modal-wormhole"></div>,`,
{ owner: this.engine }
);
assert.dom(SELECTORS.timeStartedRow).exists();
assert.dom(SELECTORS.timeFinishedRow).exists();
});
});

View File

@ -0,0 +1,315 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { click, render, fillIn } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { SELECTORS } from 'vault/tests/helpers/pki/page/pki-tidy-form';
module('Integration | Component | pki tidy form', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'pki');
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.version = this.owner.lookup('service:version');
this.version.version = '1.14.1+ent';
this.server.post('/sys/capabilities-self', () => {});
this.onSave = () => {};
this.onCancel = () => {};
this.manualTidy = this.store.createRecord('pki/tidy', { backend: 'pki-manual-tidy' });
this.store.pushPayload('pki/tidy', {
modelName: 'pki/tidy',
id: 'pki-auto-tidy',
});
this.autoTidy = this.store.peekRecord('pki/tidy', 'pki-auto-tidy');
});
test('it hides or shows fields depending on auto-tidy toggle', async function (assert) {
assert.expect(37);
this.version.version = '1.14.1+ent';
const sectionHeaders = [
'Universal operations',
'ACME operations',
'Issuer operations',
'Cross-cluster operations',
];
await render(
hbs`
<PkiTidyForm
@tidy={{this.autoTidy}}
@tidyType="auto"
@onSave={{this.onSave}}
@onCancel={{this.onCancel}}
/>
`,
{ owner: this.engine }
);
assert.dom(SELECTORS.toggleInput('intervalDuration')).isNotChecked('Automatic tidy is disabled');
assert.dom(`[data-test-ttl-form-label="Automatic tidy disabled"]`).exists('renders disabled label text');
this.autoTidy.eachAttribute((attr) => {
if (attr === 'enabled' || attr === 'intervalDuration') return;
assert.dom(SELECTORS.inputByAttr(attr)).doesNotExist(`does not render ${attr} when auto tidy disabled`);
});
sectionHeaders.forEach((group) => {
assert.dom(SELECTORS.tidySectionHeader(group)).doesNotExist(`does not render ${group} header`);
});
// ENABLE AUTO TIDY
await click(SELECTORS.toggleInput('intervalDuration'));
assert.dom(SELECTORS.toggleInput('intervalDuration')).isChecked('Automatic tidy is enabled');
assert.dom(`[data-test-ttl-form-label="Automatic tidy enabled"]`).exists('renders enabled text');
this.autoTidy.eachAttribute((attr) => {
const skipFields = ['enabled', 'tidyAcme', 'intervalDuration'];
if (skipFields.includes(attr)) return; // combined with duration ttl or asserted elsewhere
assert.dom(SELECTORS.inputByAttr(attr)).exists(`renders ${attr} when auto tidy enabled`);
});
sectionHeaders.forEach((group) => {
assert.dom(SELECTORS.tidySectionHeader(group)).exists(`renders ${group} header`);
});
});
test('it renders all attribute fields, including enterprise', async function (assert) {
assert.expect(25);
this.version.version = '1.14.1+ent';
this.autoTidy.enabled = true;
const skipFields = ['enabled', 'tidyAcme', 'intervalDuration']; // combined with duration ttl or asserted separately
await render(
hbs`
<PkiTidyForm
@tidy={{this.autoTidy}}
@tidyType="auto"
@onSave={{this.onSave}}
@onCancel={{this.onCancel}}
/>
`,
{ owner: this.engine }
);
this.autoTidy.eachAttribute((attr) => {
if (skipFields.includes(attr)) return;
assert.dom(SELECTORS.inputByAttr(attr)).exists(`renders ${attr} for auto tidyType`);
});
await render(
hbs`
<PkiTidyForm
@tidy={{this.manualTidy}}
@tidyType="manual"
@onSave={{this.onSave}}
@onCancel={{this.onCancel}}
/>
`,
{ owner: this.engine }
);
assert.dom(SELECTORS.toggleInput('intervalDuration')).doesNotExist('hides automatic tidy toggle');
this.manualTidy.eachAttribute((attr) => {
if (skipFields.includes(attr)) return;
assert.dom(SELECTORS.inputByAttr(attr)).exists(`renders ${attr} for manual tidyType`);
});
});
test('it hides enterprise fields for OSS', async function (assert) {
assert.expect(7);
this.version.version = '1.14.1';
this.autoTidy.enabled = true;
const enterpriseFields = [
'tidyRevocationQueue',
'tidyCrossClusterRevokedCerts',
'revocationQueueSafetyBuffer',
];
// tidyType = auto
await render(
hbs`
<PkiTidyForm
@tidy={{this.autoTidy}}
@tidyType="auto"
@onSave={{this.onSave}}
@onCancel={{this.onCancel}}
/>
`,
{ owner: this.engine }
);
assert
.dom(SELECTORS.tidySectionHeader('Cross-cluster operations'))
.doesNotExist(`does not render ent header`);
enterpriseFields.forEach((entAttr) => {
assert.dom(SELECTORS.inputByAttr(entAttr)).doesNotExist(`does not render ${entAttr} for auto tidyType`);
});
// tidyType = manual
await render(
hbs`
<PkiTidyForm
@tidy={{this.manualTidy}}
@tidyType="manual"
@onSave={{this.onSave}}
@onCancel={{this.onCancel}}
/>
`,
{ owner: this.engine }
);
enterpriseFields.forEach((entAttr) => {
assert
.dom(SELECTORS.inputByAttr(entAttr))
.doesNotExist(`does not render ${entAttr} for manual tidyType`);
});
});
test('it should change the attributes on the model', async function (assert) {
assert.expect(12);
this.server.post('/pki-auto-tidy/config/auto-tidy', (schema, req) => {
assert.propEqual(
JSON.parse(req.requestBody),
{
acme_account_safety_buffer: '60s',
enabled: true,
interval_duration: '10s',
issuer_safety_buffer: '20s',
pause_duration: '30s',
revocation_queue_safety_buffer: '40s',
safety_buffer: '50s',
tidy_acme: true,
tidy_cert_store: true,
tidy_cross_cluster_revoked_certs: true,
tidy_expired_issuers: true,
tidy_move_legacy_ca_bundle: true,
tidy_revocation_queue: true,
tidy_revoked_cert_issuer_associations: true,
tidy_revoked_certs: true,
},
'response contains updated model values'
);
});
await render(
hbs`
<PkiTidyForm
@tidy={{this.autoTidy}}
@tidyType="auto"
@onSave={{this.onSave}}
@onCancel={{this.onCancel}}
/>
`,
{ owner: this.engine }
);
assert.dom(SELECTORS.toggleInput('intervalDuration')).isNotChecked('Automatic tidy is disabled');
assert.dom(SELECTORS.toggleLabel('Automatic tidy disabled')).exists('auto tidy has disabled label');
assert.false(this.autoTidy.enabled, 'enabled is false on model');
// enable auto-tidy
await click(SELECTORS.toggleInput('intervalDuration'));
await fillIn(SELECTORS.intervalDuration, 10);
assert.dom(SELECTORS.toggleInput('intervalDuration')).isChecked('toggle enabled auto tidy');
assert.dom(SELECTORS.toggleLabel('Automatic tidy enabled')).exists('auto tidy has enabled label');
assert.dom(SELECTORS.toggleInput('acmeAccountSafetyBuffer')).isNotChecked('ACME tidy is disabled');
assert.dom(SELECTORS.toggleLabel('Tidy ACME disabled')).exists('ACME label has correct disabled text');
assert.false(this.autoTidy.tidyAcme, 'tidyAcme is false on model');
await click(SELECTORS.toggleInput('acmeAccountSafetyBuffer'));
await fillIn(SELECTORS.acmeAccountSafetyBuffer, 60);
assert.true(this.autoTidy.tidyAcme, 'tidyAcme toggles to true');
const fillInValues = {
issuerSafetyBuffer: 20,
pauseDuration: 30,
revocationQueueSafetyBuffer: 40,
safetyBuffer: 50,
};
this.autoTidy.eachAttribute(async (attr, { type }) => {
const skipFields = ['enabled', 'tidyAcme', 'intervalDuration', 'acmeAccountSafetyBuffer']; // combined with duration ttl or asserted separately
if (skipFields.includes(attr)) return;
if (type === 'boolean') {
await click(SELECTORS.inputByAttr(attr));
}
if (type === 'string') {
await fillIn(SELECTORS.toggleInput(attr), `${fillInValues[attr]}`);
}
});
assert.dom(SELECTORS.toggleInput('acmeAccountSafetyBuffer')).isChecked('ACME tidy is enabled');
assert.dom(SELECTORS.toggleLabel('Tidy ACME enabled')).exists('ACME label has correct enabled text');
await click(SELECTORS.tidySave);
});
test('it updates auto-tidy config', async function (assert) {
assert.expect(4);
this.server.post('/pki-auto-tidy/config/auto-tidy', (schema, req) => {
assert.ok(true, 'Request made to update auto-tidy');
assert.propEqual(
JSON.parse(req.requestBody),
{
enabled: false,
tidy_acme: false,
},
'response contains auto-tidy params'
);
});
this.onSave = () => assert.ok(true, 'onSave callback fires on save success');
this.onCancel = () => assert.ok(true, 'onCancel callback fires on save success');
await render(
hbs`
<PkiTidyForm
@tidy={{this.autoTidy}}
@tidyType="auto"
@onSave={{this.onSave}}
@onCancel={{this.onCancel}}
/>
`,
{ owner: this.engine }
);
await click(SELECTORS.tidySave);
await click(SELECTORS.tidyCancel);
});
test('it saves and performs manual tidy', async function (assert) {
assert.expect(4);
this.server.post('/pki-manual-tidy/tidy', (schema, req) => {
assert.ok(true, 'Request made to perform manual tidy');
assert.propEqual(
JSON.parse(req.requestBody),
{ tidy_acme: false },
'response contains manual tidy params'
);
});
this.onSave = () => assert.ok(true, 'onSave callback fires on save success');
this.onCancel = () => assert.ok(true, 'onCancel callback fires on save success');
await render(
hbs`
<PkiTidyForm
@tidy={{this.manualTidy}}
@tidyType="manual"
@onSave={{this.onSave}}
@onCancel={{this.onCancel}}
/>
`,
{ owner: this.engine }
);
await click(SELECTORS.tidySave);
await click(SELECTORS.tidyCancel);
});
});

View File

@ -25,7 +25,7 @@ module('Unit | Adapter | pki/tidy', function (hooks) {
assert.ok(adapter); assert.ok(adapter);
}); });
test('it calls the correct endpoint when tidyType = manual-tidy', async function (assert) { test('it calls the correct endpoint when tidyType = manual', async function (assert) {
assert.expect(1); assert.expect(1);
this.server.post(`${this.backend}/tidy`, () => { this.server.post(`${this.backend}/tidy`, () => {
@ -38,24 +38,43 @@ module('Unit | Adapter | pki/tidy', function (hooks) {
safetyBuffer: '120h', safetyBuffer: '120h',
backend: this.backend, backend: this.backend,
}; };
await this.store await this.store.createRecord('pki/tidy', this.payload).save({ adapterOptions: { tidyType: 'manual' } });
.createRecord('pki/tidy', this.payload)
.save({ adapterOptions: { tidyType: 'manual-tidy' } });
}); });
test('it calls the correct endpoint when tidyType = auto-tidy', async function (assert) { test('it should make a request to correct endpoint for findRecord', async function (assert) {
assert.expect(1); assert.expect(1);
this.server.post(`${this.backend}/config/auto-tidy`, () => { this.server.get(`${this.backend}/config/auto-tidy`, () => {
assert.ok(true, 'request made to correct endpoint on create'); assert.ok(true, 'request made to correct endpoint on create');
return {}; return {
}); request_id: '2a4a1f36-20df-e71c-02d6-be15a09656f9',
this.payload = { lease_id: '',
enabled: true, renewable: false,
interval_duration: '72h', lease_duration: 0,
backend: this.backend, data: {
acme_account_safety_buffer: 2592000,
enabled: false,
interval_duration: 43200,
issuer_safety_buffer: 31536000,
maintain_stored_certificate_counts: false,
pause_duration: '0s',
publish_stored_certificate_count_metrics: false,
revocation_queue_safety_buffer: 172800,
safety_buffer: 259200,
tidy_acme: false,
tidy_cert_store: false,
tidy_cross_cluster_revoked_certs: false,
tidy_expired_issuers: false,
tidy_move_legacy_ca_bundle: false,
tidy_revocation_queue: false,
tidy_revoked_cert_issuer_associations: false,
tidy_revoked_certs: false,
},
wrap_info: null,
warnings: null,
auth: null,
}; };
await this.store });
.createRecord('pki/tidy', this.payload)
.save({ adapterOptions: { tidyType: 'auto-tidy' } }); this.store.findRecord('pki/tidy', this.backend);
}); });
}); });

View File

@ -7,12 +7,14 @@ import Application from 'vault/adapters/application';
import Adapter from 'ember-data/adapter'; import Adapter from 'ember-data/adapter';
import ModelRegistry from 'ember-data/types/registries/model'; import ModelRegistry from 'ember-data/types/registries/model';
import PkiIssuerAdapter from 'vault/adapters/pki/issuer'; import PkiIssuerAdapter from 'vault/adapters/pki/issuer';
import PkiTidyAdapter from 'vault/adapters/pki/tidy';
/** /**
* Catch-all for ember-data. * Catch-all for ember-data.
*/ */
export default interface AdapterRegistry { export default interface AdapterRegistry {
'pki/issuer': PkiIssuerAdapter; 'pki/issuer': PkiIssuerAdapter;
'pki/tidy': PkiTidyAdapter;
application: Application; application: Application;
[key: keyof ModelRegistry]: Adapter; [key: keyof ModelRegistry]: Adapter;
} }

12
ui/types/vault/adapters/pki/tidy.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
import Store from '@ember-data/store';
import { AdapterRegistry } from 'ember-data/adapter';
export default interface PkiTidyAdapter extends AdapterRegistry {
namespace: string;
cancelTidy(backend: string);
}

29
ui/types/vault/models/pki/tidy.d.ts vendored Normal file
View File

@ -0,0 +1,29 @@
import Model from '@ember-data/model';
import { FormField, FormFieldGroups } from 'vault/vault/app-types';
export default class PkiTidyModel extends Model {
version: string;
acmeAccountSafetyBuffer: string;
tidyAcme: boolean;
enabled: boolean;
intervalDuration: string;
issuerSafetyBuffer: string;
pauseDuration: string;
revocationQueueSafetyBuffer: string;
safetyBuffer: string;
tidyCertStore: boolean;
tidyCrossClusterRevokedCerts: boolean;
tidyExpiredIssuers: boolean;
tidyMoveLegacyCaBundle: boolean;
tidyRevocationQueue: boolean;
tidyRevokedCertIssuerAssociations: boolean;
tidyRevokedCerts: boolean;
get useOpenAPI(): boolean;
getHelpUrl(backend: string): string;
allByKey: {
intervalDuration: FormField[];
};
get allGroups(): FormFieldGroups[];
get sharedFields(): FormFieldGroups[];
get formFieldGroups(): FormFieldGroups[];
}