From ec6a1f2934a0dbfdba894181f29cbdf033ae8884 Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Tue, 23 May 2023 16:05:15 -0700 Subject: [PATCH] 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 Co-authored-by: Chelsea Shaw <82459713+hashishaw@users.noreply.github.com> Co-authored-by: Kianna <30884335+kiannaquach@users.noreply.github.com> --- ui/app/adapters/pki/tidy.js | 52 +-- ui/app/models/pki/tidy.js | 165 ++++++++- ui/app/serializers/pki/tidy.js | 17 + .../core/addon/helpers/options-for-backend.js | 4 + ui/lib/pki/README.md | 12 + .../page/pki-configuration-details.hbs | 3 - .../page/pki-tidy-auto-configure.hbs | 18 + .../page/pki-tidy-auto-settings.hbs | 40 +++ .../addon/components/page/pki-tidy-form.hbs | 83 ----- .../addon/components/page/pki-tidy-manual.hbs | 18 + .../addon/components/page/pki-tidy-status.hbs | 193 +++++++++++ .../addon/components/page/pki-tidy-status.ts | 157 +++++++++ ui/lib/pki/addon/components/pki-tidy-form.hbs | 80 +++++ .../components/{page => }/pki-tidy-form.ts | 39 ++- ui/lib/pki/addon/controllers/tidy/index.js | 37 ++ ui/lib/pki/addon/routes.js | 32 +- ui/lib/pki/addon/routes/application.js | 1 + ui/lib/pki/addon/routes/error.js | 1 + ui/lib/pki/addon/routes/tidy.js | 18 + ui/lib/pki/addon/routes/tidy/auto.js | 8 + .../pki/addon/routes/tidy/auto/configure.js | 23 ++ ui/lib/pki/addon/routes/tidy/auto/index.js | 20 ++ ui/lib/pki/addon/routes/tidy/index.js | 47 +++ .../{configuration/tidy.js => tidy/manual.js} | 7 +- .../addon/templates/configuration/tidy.hbs | 1 - ui/lib/pki/addon/templates/tidy.hbs | 1 + ui/lib/pki/addon/templates/tidy/auto.hbs | 1 + .../addon/templates/tidy/auto/configure.hbs | 1 + .../pki/addon/templates/tidy/auto/index.hbs | 1 + ui/lib/pki/addon/templates/tidy/index.hbs | 20 ++ ui/lib/pki/addon/templates/tidy/manual.hbs | 1 + ui/lib/pki/package.json | 1 + ui/public/images/pki-tidy.png | Bin 0 -> 31153 bytes .../pki/pki-engine-workflow-test.js | 26 +- ui/tests/acceptance/pki/pki-tidy-test.js | 181 ++++++++++ ui/tests/helpers/pki/page/pki-tidy-form.js | 17 +- ui/tests/helpers/pki/page/pki-tidy.js | 30 ++ ui/tests/helpers/pki/workflow.js | 1 + .../pki/page/pki-tidy-auto-settings-test.js | 67 ++++ .../components/pki/page/pki-tidy-form-test.js | 58 ---- .../pki/page/pki-tidy-status-test.js | 114 +++++++ .../components/pki/pki-tidy-form-test.js | 315 ++++++++++++++++++ ui/tests/unit/adapters/pki/tidy-test.js | 49 ++- .../ember-data/types/registries/adapter.d.ts | 2 + ui/types/vault/adapters/pki/tidy.d.ts | 12 + ui/types/vault/models/pki/tidy.d.ts | 29 ++ 46 files changed, 1760 insertions(+), 243 deletions(-) create mode 100644 ui/app/serializers/pki/tidy.js create mode 100644 ui/lib/pki/addon/components/page/pki-tidy-auto-configure.hbs create mode 100644 ui/lib/pki/addon/components/page/pki-tidy-auto-settings.hbs delete mode 100644 ui/lib/pki/addon/components/page/pki-tidy-form.hbs create mode 100644 ui/lib/pki/addon/components/page/pki-tidy-manual.hbs create mode 100644 ui/lib/pki/addon/components/page/pki-tidy-status.hbs create mode 100644 ui/lib/pki/addon/components/page/pki-tidy-status.ts create mode 100644 ui/lib/pki/addon/components/pki-tidy-form.hbs rename ui/lib/pki/addon/components/{page => }/pki-tidy-form.ts (56%) create mode 100644 ui/lib/pki/addon/controllers/tidy/index.js create mode 100644 ui/lib/pki/addon/routes/tidy.js create mode 100644 ui/lib/pki/addon/routes/tidy/auto.js create mode 100644 ui/lib/pki/addon/routes/tidy/auto/configure.js create mode 100644 ui/lib/pki/addon/routes/tidy/auto/index.js create mode 100644 ui/lib/pki/addon/routes/tidy/index.js rename ui/lib/pki/addon/routes/{configuration/tidy.js => tidy/manual.js} (83%) delete mode 100644 ui/lib/pki/addon/templates/configuration/tidy.hbs create mode 100644 ui/lib/pki/addon/templates/tidy.hbs create mode 100644 ui/lib/pki/addon/templates/tidy/auto.hbs create mode 100644 ui/lib/pki/addon/templates/tidy/auto/configure.hbs create mode 100644 ui/lib/pki/addon/templates/tidy/auto/index.hbs create mode 100644 ui/lib/pki/addon/templates/tidy/index.hbs create mode 100644 ui/lib/pki/addon/templates/tidy/manual.hbs create mode 100644 ui/public/images/pki-tidy.png create mode 100644 ui/tests/acceptance/pki/pki-tidy-test.js create mode 100644 ui/tests/helpers/pki/page/pki-tidy.js create mode 100644 ui/tests/integration/components/pki/page/pki-tidy-auto-settings-test.js delete mode 100644 ui/tests/integration/components/pki/page/pki-tidy-form-test.js create mode 100644 ui/tests/integration/components/pki/page/pki-tidy-status-test.js create mode 100644 ui/tests/integration/components/pki/pki-tidy-form-test.js create mode 100644 ui/types/vault/adapters/pki/tidy.d.ts create mode 100644 ui/types/vault/models/pki/tidy.d.ts diff --git a/ui/app/adapters/pki/tidy.js b/ui/app/adapters/pki/tidy.js index 6657f8368b..41ea1be663 100644 --- a/ui/app/adapters/pki/tidy.js +++ b/ui/app/adapters/pki/tidy.js @@ -2,35 +2,49 @@ * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: MPL-2.0 */ -import { assert } from '@ember/debug'; -import { encodePath } from 'vault/utils/path-encoding-helpers'; import ApplicationAdapter from '../application'; +import { encodePath } from 'vault/utils/path-encoding-helpers'; export default class PkiTidyAdapter extends ApplicationAdapter { 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 { tidyType } = snapshot.adapterOptions; - - if (!backend) { - throw new Error('Backend missing'); + if (tidyType === 'auto') { + throw new Error('Auto tidy type models are never new, please use findRecord'); } - const baseUrl = `${this.buildURL()}/${encodePath(backend)}`; - - 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'); - } + const url = `${this._baseUrl(backend)}/tidy`; + return this.ajax(url, 'POST', { data: this.serialize(snapshot, tidyType) }); } - createRecord(store, type, snapshot) { - const url = this.urlForCreateRecord(snapshot); - return this.ajax(url, 'POST', { data: this.serialize(snapshot) }); + // saving auto-tidy config POST requests will always update + updateRecord(store, type, 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'); } } diff --git a/ui/app/models/pki/tidy.js b/ui/app/models/pki/tidy.js index e1c722a1af..4083a10e67 100644 --- a/ui/app/models/pki/tidy.js +++ b/ui/app/models/pki/tidy.js @@ -4,9 +4,168 @@ */ 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 { - @attr('boolean', { defaultValue: false }) tidyCertStore; - @attr('boolean', { defaultValue: false }) tidyRevocationQueue; - @attr('string', { defaultValue: '72h' }) safetyBuffer; + // the backend mount is the model id, only one pki/tidy model will ever persist (the auto-tidy config) + @service version; + + @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); + } } diff --git a/ui/app/serializers/pki/tidy.js b/ui/app/serializers/pki/tidy.js new file mode 100644 index 0000000000..77f2a9871d --- /dev/null +++ b/ui/app/serializers/pki/tidy.js @@ -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; + } +} diff --git a/ui/lib/core/addon/helpers/options-for-backend.js b/ui/lib/core/addon/helpers/options-for-backend.js index c1374ec2dd..075936f0b7 100644 --- a/ui/lib/core/addon/helpers/options-for-backend.js +++ b/ui/lib/core/addon/helpers/options-for-backend.js @@ -38,6 +38,10 @@ const PKI_ENGINE_BACKEND = { label: 'Certificates', link: 'certificates', }, + { + label: 'Tidy', + link: 'tidy', + }, { label: 'Configuration', link: 'configuration', diff --git a/ui/lib/pki/README.md b/ui/lib/pki/README.md index e019cfaae6..0f1895cbdd 100644 --- a/ui/lib/pki/README.md +++ b/ui/lib/pki/README.md @@ -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. +- ### [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:_ - ### [pki/issuer](../../app/models/pki/issuer.js) diff --git a/ui/lib/pki/addon/components/page/pki-configuration-details.hbs b/ui/lib/pki/addon/components/page/pki-configuration-details.hbs index d6920bdfdc..c10a86bec6 100644 --- a/ui/lib/pki/addon/components/page/pki-configuration-details.hbs +++ b/ui/lib/pki/addon/components/page/pki-configuration-details.hbs @@ -14,9 +14,6 @@
{{/if}} - - Tidy - Edit configuration diff --git a/ui/lib/pki/addon/components/page/pki-tidy-auto-configure.hbs b/ui/lib/pki/addon/components/page/pki-tidy-auto-configure.hbs new file mode 100644 index 0000000000..a7c35a25ee --- /dev/null +++ b/ui/lib/pki/addon/components/page/pki-tidy-auto-configure.hbs @@ -0,0 +1,18 @@ + + + + + +

+ + Configure automatic tidy +

+
+
+ + \ No newline at end of file diff --git a/ui/lib/pki/addon/components/page/pki-tidy-auto-settings.hbs b/ui/lib/pki/addon/components/page/pki-tidy-auto-settings.hbs new file mode 100644 index 0000000000..de5d76d209 --- /dev/null +++ b/ui/lib/pki/addon/components/page/pki-tidy-auto-settings.hbs @@ -0,0 +1,40 @@ + + + + + +

+ Automatic tidy configuration +

+
+
+ + + + + Edit auto-tidy + + + + + +
+ {{#each @model.allGroups as |group|}} + {{#each-in group as |label fields|}} + {{#if (not-eq label "autoTidy")}} +

+ {{label}} +

+ {{/if}} + + {{#each fields as |attr|}} + + {{/each}} + {{/each-in}} + {{/each}} +
\ No newline at end of file diff --git a/ui/lib/pki/addon/components/page/pki-tidy-form.hbs b/ui/lib/pki/addon/components/page/pki-tidy-form.hbs deleted file mode 100644 index 6573aca76c..0000000000 --- a/ui/lib/pki/addon/components/page/pki-tidy-form.hbs +++ /dev/null @@ -1,83 +0,0 @@ - - - - - -

- - Tidy -

-
-
- -
- -

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.

- - - -
-
- - - -
-
- - - -
- - -
- -
- - - {{#if this.invalidFormAlert}} -
- -
- {{/if}} -
- \ No newline at end of file diff --git a/ui/lib/pki/addon/components/page/pki-tidy-manual.hbs b/ui/lib/pki/addon/components/page/pki-tidy-manual.hbs new file mode 100644 index 0000000000..592919c942 --- /dev/null +++ b/ui/lib/pki/addon/components/page/pki-tidy-manual.hbs @@ -0,0 +1,18 @@ + + + + + +

+ + Manual tidy +

+
+
+ + \ No newline at end of file diff --git a/ui/lib/pki/addon/components/page/pki-tidy-status.hbs b/ui/lib/pki/addon/components/page/pki-tidy-status.hbs new file mode 100644 index 0000000000..651f000712 --- /dev/null +++ b/ui/lib/pki/addon/components/page/pki-tidy-status.hbs @@ -0,0 +1,193 @@ + + +
+ {{#if @autoTidyConfig.enabled}} + + Auto-tidy configuration + + + Perform manual tidy + + {{else}} + + {{/if}} +
+
+ +{{#if this.hasTidyConfig}} + + {{this.tidyStateAlertBanner.title}} + {{this.tidyStateAlertBanner.message}} + {{#if this.tidyStateAlertBanner.shouldShowCancelTidy}} + + {{/if}} + {{#if @tidyStatus.responseTimestamp}} + + Updated + {{date-format @tidyStatus.responseTimestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}} + + {{/if}} + + + {{#each this.tidyStatusGeneralFields as |attr|}} + + {{/each}} + +

+ {{if (eq this.tidyState "Running") "Current" "Last"}} + tidy settings +

+ {{#each this.tidyStatusConfigFields as |attr|}} + + {{/each}} + + {{#if this.isEnterprise}} + {{#each this.crossClusterOperation as |attr|}} + + {{/each}} + {{/if}} +{{else}} + + + +{{/if}} + +{{! TIDY OPTIONS MODAL }} + + +
+ + + +
+
+ +{{! CANCEL TIDY CONFIRMATION MODAL }} +{{#if this.confirmCancelTidy}} + + +
+ + +
+
+{{/if}} \ No newline at end of file diff --git a/ui/lib/pki/addon/components/page/pki-tidy-status.ts b/ui/lib/pki/addon/components/page/pki-tidy-status.ts new file mode 100644 index 0000000000..d15b8ad502 --- /dev/null +++ b/ui/lib/pki/addon/components/page/pki-tidy-status.ts @@ -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 { + @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; + } + } +} diff --git a/ui/lib/pki/addon/components/pki-tidy-form.hbs b/ui/lib/pki/addon/components/pki-tidy-form.hbs new file mode 100644 index 0000000000..2a3d6e8bf8 --- /dev/null +++ b/ui/lib/pki/addon/components/pki-tidy-form.hbs @@ -0,0 +1,80 @@ +
+ +

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. + Learn more +

+ + + +
+ {{#if (and (eq @tidyType "auto") this.intervalDurationAttr)}} + {{#let this.intervalDurationAttr as |attr|}} + + {{/let}} + {{/if}} + {{#each @tidy.formFieldGroups as |fieldGroup|}} + {{#each-in fieldGroup as |group fields|}} + {{#if (or (eq @tidyType "manual") @tidy.enabled)}} + + {{#each fields as |attr|}} + {{#if (eq attr.name "acmeAccountSafetyBuffer")}} + + {{else}} + {{! tidyAcme is handled by the ttl above }} + {{#if (not-eq attr.name "tidyAcme")}} + + {{/if}} + {{/if}} + {{/each}} + {{/if}} + {{/each-in}} + {{/each}} + +
+ +
+ + + {{#if this.invalidFormAlert}} +
+ +
+ {{/if}} +
+ \ No newline at end of file diff --git a/ui/lib/pki/addon/components/page/pki-tidy-form.ts b/ui/lib/pki/addon/components/pki-tidy-form.ts similarity index 56% rename from ui/lib/pki/addon/components/page/pki-tidy-form.ts rename to ui/lib/pki/addon/components/pki-tidy-form.ts index 0b8e62237a..ca5ee1a651 100644 --- a/ui/lib/pki/addon/components/page/pki-tidy-form.ts +++ b/ui/lib/pki/addon/components/pki-tidy-form.ts @@ -4,18 +4,31 @@ */ import Component from '@glimmer/component'; +import errorMessage from 'vault/utils/error-message'; import { action } from '@ember/object'; import { inject as service } from '@ember/service'; import { task } from 'ember-concurrency'; import { waitFor } from '@ember/test-waiters'; 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 PkiTidyModel from 'vault/models/pki/tidy'; +import type { FormField, TtlEvent } from 'vault/app-types'; interface Args { 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 { @@ -24,13 +37,8 @@ export default class PkiTidyForm extends Component { @tracked errorBanner = ''; @tracked invalidFormAlert = ''; - returnToConfiguration() { - this.router.transitionTo('vault.cluster.secrets.backend.pki.configuration.index'); - } - - @action - updateSafetyBuffer({ goSafeTimeString }: { goSafeTimeString: string }) { - this.args.tidy.safetyBuffer = goSafeTimeString; + get intervalDurationAttr() { + return this.args.tidy?.allByKey.intervalDuration; } @task @@ -38,8 +46,8 @@ export default class PkiTidyForm extends Component { *save(event: Event) { event.preventDefault(); try { - yield this.args.tidy.save({ adapterOptions: this.args.adapterOptions }); - this.returnToConfiguration(); + yield this.args.tidy.save({ adapterOptions: { tidyType: this.args.tidyType } }); + this.args.onSave(); } catch (e) { this.errorBanner = errorMessage(e); this.invalidFormAlert = 'There was an error submitting this form.'; @@ -47,7 +55,10 @@ export default class PkiTidyForm extends Component { } @action - cancel() { - this.returnToConfiguration(); + handleTtl(attr: FormField, e: TtlEvent) { + 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; } } diff --git a/ui/lib/pki/addon/controllers/tidy/index.js b/ui/lib/pki/addon/controllers/tidy/index.js new file mode 100644 index 0000000000..ffa0962866 --- /dev/null +++ b/ui/lib/pki/addon/controllers/tidy/index.js @@ -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 + } + } + } +} diff --git a/ui/lib/pki/addon/routes.js b/ui/lib/pki/addon/routes.js index 1768327c0e..6429a155fa 100644 --- a/ui/lib/pki/addon/routes.js +++ b/ui/lib/pki/addon/routes.js @@ -7,12 +7,6 @@ import buildRoutes from 'ember-engines/routes'; export default buildRoutes(function () { 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('index', { path: '/' }); this.route('create'); @@ -36,13 +30,6 @@ export default buildRoutes(function () { 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('index', { path: '/' }); this.route('create'); @@ -52,4 +39,23 @@ export default buildRoutes(function () { 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'); + }); }); diff --git a/ui/lib/pki/addon/routes/application.js b/ui/lib/pki/addon/routes/application.js index ac5472eb00..05225c6a94 100644 --- a/ui/lib/pki/addon/routes/application.js +++ b/ui/lib/pki/addon/routes/application.js @@ -24,6 +24,7 @@ export default class PkiRoute extends Route { signCsr: this.pathHelp.getNewModel('pki/sign-intermediate', mountPath), certGenerate: this.pathHelp.getNewModel('pki/certificate/generate', mountPath), certSign: this.pathHelp.getNewModel('pki/certificate/sign', mountPath), + tidy: this.pathHelp.getNewModel('pki/tidy', mountPath), }); } } diff --git a/ui/lib/pki/addon/routes/error.js b/ui/lib/pki/addon/routes/error.js index a9173e53c4..071a7f726e 100644 --- a/ui/lib/pki/addon/routes/error.js +++ b/ui/lib/pki/addon/routes/error.js @@ -21,6 +21,7 @@ export default class PkiRolesErrorRoute extends Route { { label: 'Issuers', route: 'issuers.index' }, { label: 'Keys', route: 'keys.index' }, { label: 'Certificates', route: 'certificates.index' }, + { label: 'Tidy', route: 'tidy.index' }, { label: 'Configuration', route: 'configuration.index' }, ]; controller.title = this.secretMountPath.currentPath; diff --git a/ui/lib/pki/addon/routes/tidy.js b/ui/lib/pki/addon/routes/tidy.js new file mode 100644 index 0000000000..d82b255c98 --- /dev/null +++ b/ui/lib/pki/addon/routes/tidy.js @@ -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), + }); + } +} diff --git a/ui/lib/pki/addon/routes/tidy/auto.js b/ui/lib/pki/addon/routes/tidy/auto.js new file mode 100644 index 0000000000..d52962cb46 --- /dev/null +++ b/ui/lib/pki/addon/routes/tidy/auto.js @@ -0,0 +1,8 @@ +import Route from '@ember/routing/route'; + +export default class PkiTidyAutoRoute extends Route { + model() { + const { autoTidyConfig } = this.modelFor('tidy'); + return autoTidyConfig; + } +} diff --git a/ui/lib/pki/addon/routes/tidy/auto/configure.js b/ui/lib/pki/addon/routes/tidy/auto/configure.js new file mode 100644 index 0000000000..d119b5c282 --- /dev/null +++ b/ui/lib/pki/addon/routes/tidy/auto/configure.js @@ -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' }, + ]; + } +} diff --git a/ui/lib/pki/addon/routes/tidy/auto/index.js b/ui/lib/pki/addon/routes/tidy/auto/index.js new file mode 100644 index 0000000000..bab854823d --- /dev/null +++ b/ui/lib/pki/addon/routes/tidy/auto/index.js @@ -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; + } +} diff --git a/ui/lib/pki/addon/routes/tidy/index.js b/ui/lib/pki/addon/routes/tidy/index.js new file mode 100644 index 0000000000..335b713c86 --- /dev/null +++ b/ui/lib/pki/addon/routes/tidy/index.js @@ -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(); + } + } +} diff --git a/ui/lib/pki/addon/routes/configuration/tidy.js b/ui/lib/pki/addon/routes/tidy/manual.js similarity index 83% rename from ui/lib/pki/addon/routes/configuration/tidy.js rename to ui/lib/pki/addon/routes/tidy/manual.js index 914123708a..48f2ecd05e 100644 --- a/ui/lib/pki/addon/routes/configuration/tidy.js +++ b/ui/lib/pki/addon/routes/tidy/manual.js @@ -7,8 +7,8 @@ import Route from '@ember/routing/route'; import { inject as service } from '@ember/service'; import { withConfirmLeave } from 'core/decorators/confirm-leave'; -@withConfirmLeave('model.tidy') -export default class PkiConfigurationTidyRoute extends Route { +@withConfirmLeave() +export default class PkiTidyManualRoute extends Route { @service store; @service secretMountPath; @@ -22,7 +22,8 @@ export default class PkiConfigurationTidyRoute extends Route { { label: 'secrets', route: 'secrets', linkExternal: true }, { label: this.secretMountPath.currentPath, route: 'overview' }, { label: 'configuration', route: 'configuration.index' }, - { label: 'tidy' }, + { label: 'tidy', route: 'tidy' }, + { label: 'manual' }, ]; } } diff --git a/ui/lib/pki/addon/templates/configuration/tidy.hbs b/ui/lib/pki/addon/templates/configuration/tidy.hbs deleted file mode 100644 index 24d7612bb3..0000000000 --- a/ui/lib/pki/addon/templates/configuration/tidy.hbs +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/ui/lib/pki/addon/templates/tidy.hbs b/ui/lib/pki/addon/templates/tidy.hbs new file mode 100644 index 0000000000..e2147cab02 --- /dev/null +++ b/ui/lib/pki/addon/templates/tidy.hbs @@ -0,0 +1 @@ +{{outlet}} \ No newline at end of file diff --git a/ui/lib/pki/addon/templates/tidy/auto.hbs b/ui/lib/pki/addon/templates/tidy/auto.hbs new file mode 100644 index 0000000000..e2147cab02 --- /dev/null +++ b/ui/lib/pki/addon/templates/tidy/auto.hbs @@ -0,0 +1 @@ +{{outlet}} \ No newline at end of file diff --git a/ui/lib/pki/addon/templates/tidy/auto/configure.hbs b/ui/lib/pki/addon/templates/tidy/auto/configure.hbs new file mode 100644 index 0000000000..cf2da60b40 --- /dev/null +++ b/ui/lib/pki/addon/templates/tidy/auto/configure.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/lib/pki/addon/templates/tidy/auto/index.hbs b/ui/lib/pki/addon/templates/tidy/auto/index.hbs new file mode 100644 index 0000000000..82aec5f6f7 --- /dev/null +++ b/ui/lib/pki/addon/templates/tidy/auto/index.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/lib/pki/addon/templates/tidy/index.hbs b/ui/lib/pki/addon/templates/tidy/index.hbs new file mode 100644 index 0000000000..608d5b498d --- /dev/null +++ b/ui/lib/pki/addon/templates/tidy/index.hbs @@ -0,0 +1,20 @@ + +{{#if this.model.hasConfig}} + +{{else}} + + + + Configure PKI + + +{{/if}} \ No newline at end of file diff --git a/ui/lib/pki/addon/templates/tidy/manual.hbs b/ui/lib/pki/addon/templates/tidy/manual.hbs new file mode 100644 index 0000000000..b751914f5e --- /dev/null +++ b/ui/lib/pki/addon/templates/tidy/manual.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/lib/pki/package.json b/ui/lib/pki/package.json index 5f86b47157..873f81402a 100644 --- a/ui/lib/pki/package.json +++ b/ui/lib/pki/package.json @@ -8,6 +8,7 @@ "ember-cli-babel": "*", "ember-cli-htmlbars": "*", "ember-cli-typescript": "*", + "@hashicorp/design-system-components": "*", "@types/ember": "latest", "@types/ember-data": "latest", "@types/ember-data__adapter": "latest", diff --git a/ui/public/images/pki-tidy.png b/ui/public/images/pki-tidy.png new file mode 100644 index 0000000000000000000000000000000000000000..320e9058d9347e6b171f1ec1433d46249fd359a5 GIT binary patch literal 31153 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 z { assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/${resource}`); assert @@ -71,9 +71,10 @@ module('Acceptance | pki workflow', function (hooks) { await click(SELECTORS.certsTab); assertEmptyState(assert, 'certificates'); - await click(SELECTORS.keysTab); assertEmptyState(assert, 'keys'); + await click(SELECTORS.tidyTab); + assertEmptyState(assert, 'tidy'); }); module('roles', function (hooks) { @@ -443,27 +444,6 @@ module('Acceptance | pki workflow', function (hooks) { .dom('[data-test-input="commonName"]') .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) { diff --git a/ui/tests/acceptance/pki/pki-tidy-test.js b/ui/tests/acceptance/pki/pki-tidy-test.js new file mode 100644 index 0000000000..4d6907c92a --- /dev/null +++ b/ui/tests/acceptance/pki/pki-tidy-test.js @@ -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'); + }); +}); diff --git a/ui/tests/helpers/pki/page/pki-tidy-form.js b/ui/tests/helpers/pki/page/pki-tidy-form.js index 10ac46910a..1a877f50a6 100644 --- a/ui/tests/helpers/pki/page/pki-tidy-form.js +++ b/ui/tests/helpers/pki/page/pki-tidy-form.js @@ -4,14 +4,15 @@ */ export const SELECTORS = { - tidyCertStoreLabel: '[data-test-tidy-cert-store-label]', - tidyRevocationList: '[data-test-tidy-revocation-queue-label]', - safetyBufferTTL: '[data-test-ttl-inputs]', - tidyCertStoreCheckbox: '[data-test-tidy-cert-store-checkbox]', - tidyRevocationCheckbox: '[data-test-tidy-revocation-queue-checkbox]', - safetyBufferInput: '[data-test-ttl-value="Safety buffer"]', - safetyBufferInputDropdown: '[data-test-select="ttl-unit"]', - tidyToolbar: '[data-test-tidy-toolbar]', + tidyFormName: (attr) => `[data-test-tidy-form="${attr}"]`, + inputByAttr: (attr) => `[data-test-input="${attr}"]`, + toggleInput: (attr) => `[data-test-input="${attr}"] input`, + intervalDuration: '[data-test-ttl-value="Automatic tidy enabled"]', + acmeAccountSafetyBuffer: '[data-test-ttl-value="Tidy ACME enabled"]', + toggleLabel: (label) => `[data-test-toggle-label="${label}"]`, + tidySectionHeader: (header) => `[data-test-tidy-header="${header}"]`, tidySave: '[data-test-pki-tidy-button]', tidyCancel: '[data-test-pki-tidy-cancel]', + tidyPauseDuration: '[data-test-ttl-value="Pause duration"]', + editAutoTidyButton: '[data-test-pki-edit-tidy-auto-link]', }; diff --git a/ui/tests/helpers/pki/page/pki-tidy.js b/ui/tests/helpers/pki/page/pki-tidy.js new file mode 100644 index 0000000000..783efc8d60 --- /dev/null +++ b/ui/tests/helpers/pki/page/pki-tidy.js @@ -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, + }, +}; diff --git a/ui/tests/helpers/pki/workflow.js b/ui/tests/helpers/pki/workflow.js index 5b56734d88..b576120046 100644 --- a/ui/tests/helpers/pki/workflow.js +++ b/ui/tests/helpers/pki/workflow.js @@ -28,6 +28,7 @@ export const SELECTORS = { issuersTab: '[data-test-secret-list-tab="Issuers"]', certsTab: '[data-test-secret-list-tab="Certificates"]', keysTab: '[data-test-secret-list-tab="Keys"]', + tidyTab: '[data-test-secret-list-tab="Tidy"]', configTab: '[data-test-secret-list-tab="Configuration"]', // ROLES deleteRoleButton: '[data-test-pki-role-delete]', diff --git a/ui/tests/integration/components/pki/page/pki-tidy-auto-settings-test.js b/ui/tests/integration/components/pki/page/pki-tidy-auto-settings-test.js new file mode 100644 index 0000000000..be518463e3 --- /dev/null +++ b/ui/tests/integration/components/pki/page/pki-tidy-auto-settings-test.js @@ -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``, + 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'); + }); +}); diff --git a/ui/tests/integration/components/pki/page/pki-tidy-form-test.js b/ui/tests/integration/components/pki/page/pki-tidy-form-test.js deleted file mode 100644 index 5fc776019a..0000000000 --- a/ui/tests/integration/components/pki/page/pki-tidy-form-test.js +++ /dev/null @@ -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``, { - 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``, { - 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'); - }); -}); diff --git a/ui/tests/integration/components/pki/page/pki-tidy-status-test.js b/ui/tests/integration/components/pki/page/pki-tidy-status-test.js new file mode 100644 index 0000000000..1a05407f1c --- /dev/null +++ b/ui/tests/integration/components/pki/page/pki-tidy-status-test.js @@ -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` ,`, + { 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` ,`, + { owner: this.engine } + ); + assert.dom(SELECTORS.hdsAlertTitle).hasText('Tidy is inactive'); + // finished state + this.tidyStatus.state = 'Finished'; + await render( + hbs` ,`, + { owner: this.engine } + ); + assert.dom(SELECTORS.hdsAlertTitle).hasText('Tidy operation finished'); + // error state + this.tidyStatus.state = 'Error'; + await render( + hbs` ,`, + { owner: this.engine } + ); + assert.dom(SELECTORS.hdsAlertTitle).hasText('Tidy operation failed'); + // cancelling state + this.tidyStatus.state = 'Cancelling'; + await render( + hbs` ,`, + { owner: this.engine } + ); + assert.dom(SELECTORS.hdsAlertTitle).hasText('Tidy operation cancelling'); + // cancelled state + this.tidyStatus.state = 'Cancelled'; + await render( + hbs` ,`, + { 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` ,`, + { owner: this.engine } + ); + assert.dom(SELECTORS.timeStartedRow).exists(); + assert.dom(SELECTORS.timeFinishedRow).exists(); + }); +}); diff --git a/ui/tests/integration/components/pki/pki-tidy-form-test.js b/ui/tests/integration/components/pki/pki-tidy-form-test.js new file mode 100644 index 0000000000..3eadc2aeb8 --- /dev/null +++ b/ui/tests/integration/components/pki/pki-tidy-form-test.js @@ -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` + + `, + { 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` + + `, + { 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` + + `, + { 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` + + `, + { 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` + + `, + { 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` + + `, + { 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` + + `, + { 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` + + `, + { owner: this.engine } + ); + + await click(SELECTORS.tidySave); + await click(SELECTORS.tidyCancel); + }); +}); diff --git a/ui/tests/unit/adapters/pki/tidy-test.js b/ui/tests/unit/adapters/pki/tidy-test.js index b82dda4d20..fd27c951df 100644 --- a/ui/tests/unit/adapters/pki/tidy-test.js +++ b/ui/tests/unit/adapters/pki/tidy-test.js @@ -25,7 +25,7 @@ module('Unit | Adapter | pki/tidy', function (hooks) { 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); this.server.post(`${this.backend}/tidy`, () => { @@ -38,24 +38,43 @@ module('Unit | Adapter | pki/tidy', function (hooks) { safetyBuffer: '120h', backend: this.backend, }; - await this.store - .createRecord('pki/tidy', this.payload) - .save({ adapterOptions: { tidyType: 'manual-tidy' } }); + await this.store.createRecord('pki/tidy', this.payload).save({ adapterOptions: { tidyType: 'manual' } }); }); - 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); - 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'); - return {}; + return { + request_id: '2a4a1f36-20df-e71c-02d6-be15a09656f9', + lease_id: '', + renewable: false, + lease_duration: 0, + 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, + }; }); - this.payload = { - enabled: true, - interval_duration: '72h', - backend: this.backend, - }; - await this.store - .createRecord('pki/tidy', this.payload) - .save({ adapterOptions: { tidyType: 'auto-tidy' } }); + + this.store.findRecord('pki/tidy', this.backend); }); }); diff --git a/ui/types/ember-data/types/registries/adapter.d.ts b/ui/types/ember-data/types/registries/adapter.d.ts index 57d6ccc59f..abca4c3582 100644 --- a/ui/types/ember-data/types/registries/adapter.d.ts +++ b/ui/types/ember-data/types/registries/adapter.d.ts @@ -7,12 +7,14 @@ import Application from 'vault/adapters/application'; import Adapter from 'ember-data/adapter'; import ModelRegistry from 'ember-data/types/registries/model'; import PkiIssuerAdapter from 'vault/adapters/pki/issuer'; +import PkiTidyAdapter from 'vault/adapters/pki/tidy'; /** * Catch-all for ember-data. */ export default interface AdapterRegistry { 'pki/issuer': PkiIssuerAdapter; + 'pki/tidy': PkiTidyAdapter; application: Application; [key: keyof ModelRegistry]: Adapter; } diff --git a/ui/types/vault/adapters/pki/tidy.d.ts b/ui/types/vault/adapters/pki/tidy.d.ts new file mode 100644 index 0000000000..ac770aa632 --- /dev/null +++ b/ui/types/vault/adapters/pki/tidy.d.ts @@ -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); +} diff --git a/ui/types/vault/models/pki/tidy.d.ts b/ui/types/vault/models/pki/tidy.d.ts new file mode 100644 index 0000000000..b95d1ed6a3 --- /dev/null +++ b/ui/types/vault/models/pki/tidy.d.ts @@ -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[]; +}