diff --git a/changelog/21871.txt b/changelog/21871.txt new file mode 100644 index 0000000000..8333603efc --- /dev/null +++ b/changelog/21871.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: update unseal and DR operation token flow components +``` \ No newline at end of file diff --git a/ui/app/adapters/cluster.js b/ui/app/adapters/cluster.js index c750b6596b..6cb9ee568e 100644 --- a/ui/app/adapters/cluster.js +++ b/ui/app/adapters/cluster.js @@ -211,17 +211,19 @@ export default ApplicationAdapter.extend({ }, generateDrOperationToken(data, options) { - let verb = options && options.checkStatus ? 'GET' : 'PUT'; - if (options.cancel) { - verb = 'DELETE'; - } + let verb = 'POST'; let url = `${this.buildURL()}/replication/dr/secondary/generate-operation-token/`; - if (!data || data.pgp_key || data.attempt) { - // start the generation - url = url + 'attempt'; + if (options?.cancel) { + verb = 'DELETE'; + url += 'attempt'; + } else if (options?.checkStatus) { + verb = 'GET'; + url += 'attempt'; + } else if (data?.pgp_key || data?.attempt) { + url += 'attempt'; } else { // progress the operation - url = url + 'update'; + url += 'update'; } return this.ajax(url, verb, { data, diff --git a/ui/app/controllers/vault/cluster/unseal.js b/ui/app/controllers/vault/cluster/unseal.js index a59fcde856..79fcf26b4b 100644 --- a/ui/app/controllers/vault/cluster/unseal.js +++ b/ui/app/controllers/vault/cluster/unseal.js @@ -15,6 +15,10 @@ export default Controller.extend({ }); }, + reloadCluster() { + return this.model.reload(); + }, + isUnsealed(data) { return data.sealed === false; }, diff --git a/ui/app/styles/components/vlt-radio.scss b/ui/app/styles/components/vlt-radio.scss deleted file mode 100644 index 972e63fcbc..0000000000 --- a/ui/app/styles/components/vlt-radio.scss +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -.vlt-radio { - position: relative; - input[type='radio'] { - position: absolute; - z-index: 1; - opacity: 0; - } - - input[type='radio'] + label { - content: ''; - border: 1px solid $grey-light; - border-radius: 50%; - cursor: pointer; - display: inline-block; - margin: 0.25rem 0; - height: 1rem; - width: 1rem; - flex-shrink: 0; - flex-grow: 0; - position: relative; - left: 0; - top: 0.3rem; - } - - input[type='radio']:checked + label { - content: ''; - background: $blue; - border: 1px solid $blue; - box-shadow: inset 0 0 0 0.15rem $white; - position: relative; - left: 0; - } - input[type='radio']:focus + label { - content: ''; - box-shadow: 0 0 10px 1px rgba($blue, 0.4), inset 0 0 0 0.15rem $white; - position: relative; - left: 0; - } -} diff --git a/ui/app/styles/core.scss b/ui/app/styles/core.scss index f91971a438..e5592a4b57 100644 --- a/ui/app/styles/core.scss +++ b/ui/app/styles/core.scss @@ -120,5 +120,4 @@ @import './components/unseal-warning'; // @import './components/ui-wizard'; // remove, see PR https://github.com/hashicorp/vault/pull/19220 @import './components/vault-loading'; -@import './components/vlt-radio'; @import './components/vlt-table'; diff --git a/ui/app/templates/components/raft-join.hbs b/ui/app/templates/components/raft-join.hbs index 3b29fe7aac..1c35497c5a 100644 --- a/ui/app/templates/components/raft-join.hbs +++ b/ui/app/templates/components/raft-join.hbs @@ -25,7 +25,7 @@
How do you want to get started? -
+
- - Join an existing Raft cluster +
-
+
- - Create a new Raft cluster +
diff --git a/ui/app/templates/vault/cluster/unseal.hbs b/ui/app/templates/vault/cluster/unseal.hbs index 98f9598c63..b903da7e46 100644 --- a/ui/app/templates/vault/cluster/unseal.hbs +++ b/ui/app/templates/vault/cluster/unseal.hbs @@ -31,21 +31,27 @@ is {{if this.model.unsealed "unsealed" "sealed"}}

-

- Unseal Vault by entering portions of the unseal key. This can be done via multiple mechanisms on multiple - computers. Once all portions are entered, the root key will be decrypted and Vault will unseal. -

+ {{#if this.model.unsealed}} +

Please wait while we redirect you.

+ {{else}} +

+ Unseal Vault by entering portions of the unseal key. This can be done via multiple mechanisms on multiple + computers. Once all portions are entered, the root key will be decrypted and Vault will unseal. +

+ + {{/if}}
-
diff --git a/ui/lib/core/addon/components/choose-pgp-key-form.hbs b/ui/lib/core/addon/components/choose-pgp-key-form.hbs new file mode 100644 index 0000000000..e6ae1580b6 --- /dev/null +++ b/ui/lib/core/addon/components/choose-pgp-key-form.hbs @@ -0,0 +1,64 @@ +{{#if this.selectedPgp}} +
+
+

+ {{or + @confirmText + (concat + 'Below is the base-64 encoded PGP Key that will be used. Click the "' this.buttonText '" button to proceed.' + ) + }} +

+

+ {{concat "PGP Key " this.pgpKeyFile.filename}} +

+
+ + {{this.pgpKey}} +
+
+
+
+ +
+
+ +
+
+
+{{else}} +
+
+

+ {{this.formText}} +

+ +
+
+
+ +
+
+ +
+
+
+{{/if}} \ No newline at end of file diff --git a/ui/lib/core/addon/components/choose-pgp-key-form.js b/ui/lib/core/addon/components/choose-pgp-key-form.js new file mode 100644 index 0000000000..eeb7916b86 --- /dev/null +++ b/ui/lib/core/addon/components/choose-pgp-key-form.js @@ -0,0 +1,53 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +const pgpKeyFileDefault = () => ({ value: '' }); + +/** + * @module ChoosePgpKeyForm + * ChoosePgpKeyForm component is used for DR Operation Token Generation workflow. It provides + * an interface for the user to upload or paste a PGP key for use + * + * @example + * ```js + * + * ``` + * @param {function} onCancel - required - This function will be triggered when the modal intends to be closed + * @param {function} onSubmit - required - When the PGP key is confirmed, it will call this method with the pgpKey value as the only param + * @param {string} buttonText - Button text for onSubmit. Defaults to "Continue with key" + * @param {string} formText - Form text above where the users uploads or pastes the key. Has default + */ +export default class ChoosePgpKeyForm extends Component { + @tracked pgpKeyFile = pgpKeyFileDefault(); + @tracked selectedPgp = ''; + + get pgpKey() { + return this.pgpKeyFile.value; + } + + get buttonText() { + return this.args.buttonText || 'Continue with key'; + } + + get formText() { + return ( + this.args.formText || + 'Choose a PGP Key from your computer or paste the contents of one in the form below.' + ); + } + + @action setKey(_, keyFile) { + this.pgpKeyFile = keyFile; + } + + // Form submit actions: + @action usePgpKey(evt) { + evt.preventDefault(); + this.selectedPgp = this.pgpKey; + } + @action handleSubmit(evt) { + evt.preventDefault(); + this.args.onSubmit(this.pgpKey); + } +} diff --git a/ui/lib/core/addon/components/shamir-flow.js b/ui/lib/core/addon/components/shamir-flow.js deleted file mode 100644 index 7346078493..0000000000 --- a/ui/lib/core/addon/components/shamir-flow.js +++ /dev/null @@ -1,192 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import { inject as service } from '@ember/service'; -import { gt } from '@ember/object/computed'; -import { camelize } from '@ember/string'; -import Component from '@ember/component'; -import { get, computed } from '@ember/object'; -import layout from '../templates/components/shamir-flow'; -import { A } from '@ember/array'; - -const pgpKeyFileDefault = () => ({ value: '' }); -const DEFAULTS = { - key: null, - loading: false, - errors: A(), - threshold: null, - progress: null, - pgp_key: null, - haveSavedPGPKey: false, - started: false, - generateWithPGP: false, - pgpKeyFile: pgpKeyFileDefault(), - nonce: '', -}; - -export default Component.extend(DEFAULTS, { - tagName: '', - store: service(), - formText: null, - fetchOnInit: false, - buttonText: 'Submit', - thresholdPath: 'required', - generateAction: false, - layout, - - init() { - this._super(...arguments); - if (this.fetchOnInit) { - this.attemptProgress(); - } - }, - - didInsertElement() { - this._super(...arguments); - this.onUpdate(this.getProperties(Object.keys(DEFAULTS))); - }, - - onUpdate() {}, - onLicenseError() {}, - onShamirSuccess() {}, - // can be overridden w/an attr - isComplete(data) { - return data.complete === true; - }, - - stopLoading() { - this.setProperties({ - loading: false, - errors: [], - key: null, - }); - }, - - reset() { - this.setProperties(DEFAULTS); - }, - - hasProgress: gt('progress', 0), - - actionSuccess(resp) { - const { onUpdate, isComplete, onShamirSuccess, thresholdPath } = this; - const threshold = get(resp, thresholdPath); - const props = { - ...resp, - threshold, - }; - this.stopLoading(); - // if we have an OTP, but update doesn't include one, - // we don't want to null it out - if (this.otp && !props.otp) { - delete props.otp; - } - this.setProperties(props); - onUpdate(props); - if (isComplete(props)) { - this.reset(); - onShamirSuccess(props); - } - }, - - actionError(e) { - this.stopLoading(); - if (e.httpStatus === 400) { - this.set('errors', e.errors); - } else { - // if licensing error, trigger parent method to handle - if (e.httpStatus === 500 && e.errors?.join(' ').includes('licensing is in an invalid state')) { - this.onLicenseError(); - } - throw e; - } - }, - - generateStep: computed('generateWithPGP', 'haveSavedPGPKey', 'pgp_key', function () { - const { generateWithPGP, pgp_key, haveSavedPGPKey } = this; - if (!generateWithPGP && !pgp_key) { - return 'chooseMethod'; - } - if (generateWithPGP) { - if (pgp_key && haveSavedPGPKey) { - return 'beginGenerationWithPGP'; - } else { - return 'providePGPKey'; - } - } - return ''; - }), - - extractData(data) { - const isGenerate = this.generateAction; - const hasStarted = this.started; - const usePGP = this.generateWithPGP; - const nonce = this.nonce; - - if (!isGenerate || hasStarted) { - if (nonce) { - data.nonce = nonce; - } - return data; - } - - if (usePGP) { - return { - pgp_key: data.pgp_key, - }; - } - - return { - attempt: data.attempt, - }; - }, - - attemptProgress(data) { - const checkStatus = data ? false : true; - let action = this.action; - action = action && camelize(action); - this.set('loading', true); - const adapter = this.store.adapterFor('cluster'); - const method = adapter[action]; - - method.call(adapter, data, { checkStatus }).then( - (resp) => this.actionSuccess(resp), - (...args) => this.actionError(...args) - ); - }, - - actions: { - reset() { - this.reset(); - this.set('encoded_token', null); - this.set('otp', null); - }, - - onSubmit(data) { - if (!data.key) { - return; - } - this.attemptProgress(this.extractData(data)); - }, - - startGenerate(data) { - if (this.generateAction) { - data.attempt = true; - } - this.attemptProgress(this.extractData(data)); - }, - - setKey(_, keyFile) { - this.set('pgp_key', keyFile.value); - this.set('pgpKeyFile', keyFile); - }, - - savePGPKey() { - if (this.pgp_key) { - this.set('haveSavedPGPKey', true); - } - }, - }, -}); diff --git a/ui/lib/core/addon/components/shamir-modal-flow.js b/ui/lib/core/addon/components/shamir-modal-flow.js deleted file mode 100644 index a36f039b12..0000000000 --- a/ui/lib/core/addon/components/shamir-modal-flow.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -/** - * @module ShamirModalFlow - * ShamirModalFlow is an extension of the ShamirFlow component that does the Generate Action Token workflow inside of a Modal. - * Please note, this is not an extensive list of the required parameters -- please see ShamirFlow for others - * - * @example - * ```js - * This copy is the main paragraph when the token flow has not started - * ``` - * @param {function} onClose - This function will be triggered when the modal intends to be closed - */ -import { inject as service } from '@ember/service'; -import ShamirFlow from './shamir-flow'; -import layout from '../templates/components/shamir-modal-flow'; - -export default ShamirFlow.extend({ - layout, - store: service(), - onClose: () => {}, - actions: { - onCancelClose() { - if (this.encoded_token) { - this.send('reset'); - } else if (this.generateAction && !this.started) { - if (this.generateStep !== 'chooseMethod') { - this.send('reset'); - } - } else { - const adapter = this.store.adapterFor('cluster'); - adapter.generateDrOperationToken(this.model, { cancel: true }); - this.send('reset'); - } - this.onClose(); - }, - onClose() { - this.onClose(); - }, - }, -}); diff --git a/ui/lib/core/addon/components/shamir/dr-token-flow.hbs b/ui/lib/core/addon/components/shamir/dr-token-flow.hbs new file mode 100644 index 0000000000..019de46032 --- /dev/null +++ b/ui/lib/core/addon/components/shamir/dr-token-flow.hbs @@ -0,0 +1,135 @@ + +
+ +
\ No newline at end of file diff --git a/ui/lib/core/addon/components/shamir/dr-token-flow.js b/ui/lib/core/addon/components/shamir/dr-token-flow.js new file mode 100644 index 0000000000..a31a4669e1 --- /dev/null +++ b/ui/lib/core/addon/components/shamir/dr-token-flow.js @@ -0,0 +1,126 @@ +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import ShamirFlowComponent from './flow'; + +/** + * @module ShamirDrTokenFlowComponent + * ShamirDrTokenFlow is an extension of the ShamirFlow component that does the Generate Action Token workflow inside of a Modal. + * Please note, this is not an extensive list of the required parameters -- please see ShamirFlow for others + * + * @example + * ```js + * + * ``` + * @param {string} action - required kebab-case-string which refers to an action within the cluster adapter + * @param {function} onCancel - if provided, function will be triggered on Cancel + */ +export default class ShamirDrTokenFlowComponent extends ShamirFlowComponent { + @tracked generateWithPGP = false; // controls which form shows + @tracked savedPgpKey = null; + @tracked otp = ''; + + constructor() { + super(...arguments); + // Fetch status on init + this.attemptProgress(); + } + + reset() { + this.generateWithPGP = false; + this.savedPgpKey = null; + this.otp = ''; + // tracked items on Shamir/Flow + this.attemptResponse = null; + this.errors = null; + } + + // Values calculated from the attempt response + get encodedToken() { + return this.attemptResponse?.encoded_token; + } + get started() { + return this.attemptResponse?.started; + } + get nonce() { + return this.attemptResponse?.nonce; + } + get progress() { + return this.attemptResponse?.progress; + } + get threshold() { + return this.attemptResponse?.required; + } + get pgpText() { + return { + confirm: `Below is the base-64 encoded PGP Key that will be used to encrypt the generated operation token. Next we'll enter portions of the root key to generate an operation token. Click the "Generate operation token" button to proceed.`, + form: `Choose a PGP Key from your computer or paste the contents of one in the form below. This key will be used to Encrypt the generated operation token.`, + }; + } + + // Methods which override those in Shamir/Flow + extractData(data) { + if (this.started) { + if (this.nonce) { + data.nonce = this.nonce; + } + return data; + } + if (this.savedPgpKey) { + return { + pgp_key: this.savedPgpKey, + }; + } + // only if !started + return { + attempt: data.attempt, + }; + } + + updateProgress(response) { + if (response.otp) { + // OTP is sticky -- once we get one we don't want to remove it + // even if the current response doesn't include one. + // See PR #5818 + this.otp = response.otp; + } + this.attemptResponse = response; + return; + } + + @action + usePgpKey(keyfile) { + this.savedPgpKey = keyfile; + this.attemptProgress(this.extractData({ attempt: true })); + } + + @action + startGenerate(evt) { + evt.preventDefault(); + this.attemptProgress(this.extractData({ attempt: true })); + } + + @action + async onCancelClose() { + if (!this.encodedToken && this.started) { + const adapter = this.store.adapterFor('cluster'); + await adapter.generateDrOperationToken({}, { cancel: true }); + } + this.reset(); + if (this.args.onCancel) { + this.args.onCancel(); + } + } +} + +/* generate-operation-token response example +{ + "started": true, + "nonce": "2dbd10f1-8528-6246-09e7-82b25b8aba63", + "progress": 1, + "required": 3, + "encoded_token": "", + "otp": "2vPFYG8gUSW9npwzyvxXMug0", + "otp_length": 24, + "complete": false +} +*/ diff --git a/ui/lib/core/addon/components/shamir/flow.hbs b/ui/lib/core/addon/components/shamir/flow.hbs new file mode 100644 index 0000000000..75aaf506de --- /dev/null +++ b/ui/lib/core/addon/components/shamir/flow.hbs @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/ui/lib/core/addon/components/shamir/flow.js b/ui/lib/core/addon/components/shamir/flow.js new file mode 100644 index 0000000000..303acaa471 --- /dev/null +++ b/ui/lib/core/addon/components/shamir/flow.js @@ -0,0 +1,168 @@ +import { A } from '@ember/array'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import { camelize } from '@ember/string'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +/** + * @module ShamirFlowComponent + * These components are used to manage keeping track of a shamir unseal flow. + * This component is generic and can be overwritten for various shamir use cases. + * The lifecycle for a Shamir flow is as follows: + * 1. Start (optional) + * 2. Attempt progress + * 3. Check progress + * 4. Check complete + * + * @example + * ```js + * + * ``` + * + * @param {string} action - adapter method name (kebab case) to call on attempt + * @param {number} threshold - number of keys required to unlock + * @param {number} progress - number of keys given so far for unlock + * @param {string} inputLabel - (optional) Label for key input + * @param {string} buttonText - (optional) CTA for the form submit button. Defaults to "Submit" + * @param {Function} extractData - (optional) modify the payload before the action is called + * @param {Function} updateProgress - (optional) call a side effect to check if progress has been made + * @param {Function} checkComplete - (optional) custom logic based on adapter response. Should return boolean. + * @param {Function} onShamirSuccess - method called when shamir unlock is complete. + * @param {Function} onLicenseError - method called when shamir unlock fails due to licensing error + * + */ +export default class ShamirFlowComponent extends Component { + @service store; + @tracked errors = A(); + @tracked attemptResponse = null; + + get action() { + if (!this.args.action) return ''; + return camelize(this.args.action); + } + + extractData(data) { + if (this.args.extractData) { + // custom data extraction + return this.args.extractData(data); + } + + // This method can be overwritten by extended components + // to control what data is passed into the method action + if (this.attemptResponse?.nonce) { + data.nonce = this.attemptResponse.nonce; + } + return data; + } + + /** + * 2. Attempt progress. This method assumes the correct data + * has already been extracted (use this.extractData to customize) + * @param {object} data arbitrary data which will be passed to adapter method + * @returns Promise which should resolve unless throwing error to parent. + */ + async attemptProgress(data) { + this.errors = null; + const action = this.action; + const adapter = this.store.adapterFor('cluster'); + const method = adapter[action]; + // Only used for DR token generate + const checkStatus = data ? false : true; + + try { + const resp = await method.call(adapter, data, { checkStatus }); + this.updateProgress(resp); + this.handleComplete(resp); + return; + } catch (e) { + if (e.httpStatus === 400) { + this.errors = e.errors; + return; + } else { + // if licensing error, trigger parent method to handle + if (e.httpStatus === 500 && e.errors?.join(' ').includes('licensing is in an invalid state')) { + this.args.onLicenseError(); + } + throw e; + } + } + } + + /** + * 3. This method gets called after successful unseal attempt. + * By default the response will be made available to the component, + * but pass in @updateProgress (no params) to trigger any side effects that will + * update passed attributes from parent. + * @param {payload} response from the adapter method + * @returns void + */ + updateProgress(response) { + if (this.args.updateProgress) { + this.args.updateProgress(); + } + this.attemptResponse = response; + return; + } + + /** + * 4. checkComplete checks the payload for completeness, then then + * takes calls @onShamirSuccess with no arguments if complete. + * For custom logic, define @checkComplete which receives the + * adapter payload. + * @param {payload} response from the adapter method + * @returns void + */ + handleComplete(response) { + const isComplete = this.checkComplete(response); + if (isComplete) { + if (this.args.onShamirSuccess) { + this.args.onShamirSuccess(); + } + } + return; + } + + checkComplete(response) { + if (this.args.checkComplete) { + return this.args.checkComplete(response); + } + return response.complete === true; + } + + reset() { + this.attemptResponse = null; + this.errors = null; + } + + @action + onSubmitKey(data) { + this.attemptProgress(this.extractData(data)); + } +} + +/* example unseal response (progress) +{ + "sealed": true, + "t": 3, + "n": 5, + "progress": 2, + "version": "0.6.2" +} + +example unseal response (finished) +{ + "sealed": false, + "t": 3, + "n": 5, + "progress": 0, + "version": "0.6.2", + "cluster_name": "vault-cluster-d6ec3c7f", + "cluster_id": "3e8b3fec-3749-e056-ba41-b62a63b997e8" +} +*/ diff --git a/ui/lib/core/addon/components/shamir/form.hbs b/ui/lib/core/addon/components/shamir/form.hbs new file mode 100644 index 0000000000..bfe99d718b --- /dev/null +++ b/ui/lib/core/addon/components/shamir/form.hbs @@ -0,0 +1,48 @@ +
+ {{#if @errors}} +
+ +
+ {{/if}} +
+ {{#if @otp}} + + Info + + Below is the generated OTP. This will be used to encode the generated Operation Token. Make sure to save this, as + you will need it later to decode the Operation Token. + + +
+ +

+ One Time Password (otp) +

+ {{@otp}} +
+ {{/if}} + {{#if (has-block)}} + {{yield}} + {{/if}} +
+
+ +
+ +
+
+
+
+ +
+
+ {{#if this.showProgress}} + + {{/if}} +
+
+
\ No newline at end of file diff --git a/ui/lib/core/addon/components/shamir/form.js b/ui/lib/core/addon/components/shamir/form.js new file mode 100644 index 0000000000..949adf72cc --- /dev/null +++ b/ui/lib/core/addon/components/shamir/form.js @@ -0,0 +1,61 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +/** + * @module ShamirFormComponent + * These components are used to make progress against a Shamir seal. + * Depending on the response, and external polling, the component will show + * progress and optional + * + * @example + * ```js + * + * ``` + * + * @param {Function} onSubmit - method which handles the action for shamir. Receives { key } + * @param {number} threshold - min number of keys required to unlock shamir seal + * @param {number} progress - number of keys given so far for unlock + * @param {string} buttonText - CTA for the form submit button. Defaults to "Submit" + * @param {string} inputLabel - Label for key input. Defaults to "Shamir key portion" + * @param {boolean} alwaysShowProgress - determines if the shamir progress should always show, or only when > 0 progress + * @param {string} otp - if otp is present, it will show a section describing what to do with it + * + */ +export default class ShamirFormComponent extends Component { + @tracked key = ''; + @tracked loading = false; + + get buttonText() { + return this.args.buttonText || 'Submit'; + } + get showProgress() { + return this.args.progress > 0 || this.args.alwaysShowProgress; + } + get inputLabel() { + return this.args.inputLabel || 'Shamir key portion'; + } + + resetForm() { + this.key = ''; + this.loading = false; + } + + @action + async onSubmit(key, evt) { + evt.preventDefault(); + + if (!key) { + return; + } + // Parent handles action and passes in errors if present + await this.args.onSubmit({ key }); + this.resetForm(); + } +} diff --git a/ui/lib/core/addon/templates/components/replication-action-generate-token.hbs b/ui/lib/core/addon/templates/components/replication-action-generate-token.hbs index 2c6c1bfb1f..b17a8b441d 100644 --- a/ui/lib/core/addon/templates/components/replication-action-generate-token.hbs +++ b/ui/lib/core/addon/templates/components/replication-action-generate-token.hbs @@ -20,17 +20,16 @@
- -

- Updating or promoting this cluster requires an operation token, generated by inputting the root key shares. If you'd like - to first encrypt the token with a PGP Key, click "Encrypt with PGP key" below, otherwise we can begin generation of the - operation token. -

-
\ No newline at end of file + {{#if this.isModalActive}} + {{! Wrapped in if statement so the Shamir constructor fires on modal open }} + + {{! Section & Footer is in child component since the form must do side effects on cancel }} + {{/if}} + \ No newline at end of file diff --git a/ui/lib/core/addon/templates/components/shamir-modal-flow.hbs b/ui/lib/core/addon/templates/components/shamir-modal-flow.hbs deleted file mode 100644 index b4b4d046b9..0000000000 --- a/ui/lib/core/addon/templates/components/shamir-modal-flow.hbs +++ /dev/null @@ -1,219 +0,0 @@ - - -
- -
-
\ No newline at end of file diff --git a/ui/lib/core/app/components/choose-pgp-key-form.js b/ui/lib/core/app/components/choose-pgp-key-form.js new file mode 100644 index 0000000000..68286c9d9c --- /dev/null +++ b/ui/lib/core/app/components/choose-pgp-key-form.js @@ -0,0 +1 @@ +export { default } from 'core/components/choose-pgp-key-form'; diff --git a/ui/lib/core/app/components/shamir-flow.js b/ui/lib/core/app/components/shamir-flow.js deleted file mode 100644 index 86f7cfad4d..0000000000 --- a/ui/lib/core/app/components/shamir-flow.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -export { default } from 'core/components/shamir-flow'; diff --git a/ui/lib/core/app/components/shamir-modal-flow.js b/ui/lib/core/app/components/shamir-modal-flow.js deleted file mode 100644 index f7e78e549b..0000000000 --- a/ui/lib/core/app/components/shamir-modal-flow.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -export { default } from 'core/components/shamir-modal-flow'; diff --git a/ui/lib/core/app/components/shamir/dr-token-flow.js b/ui/lib/core/app/components/shamir/dr-token-flow.js new file mode 100644 index 0000000000..46629e2848 --- /dev/null +++ b/ui/lib/core/app/components/shamir/dr-token-flow.js @@ -0,0 +1 @@ +export { default } from 'core/components/shamir/dr-token-flow'; diff --git a/ui/lib/core/app/components/shamir/flow.js b/ui/lib/core/app/components/shamir/flow.js new file mode 100644 index 0000000000..26fd77e4c3 --- /dev/null +++ b/ui/lib/core/app/components/shamir/flow.js @@ -0,0 +1 @@ +export { default } from 'core/components/shamir/flow'; diff --git a/ui/lib/core/app/components/shamir/form.js b/ui/lib/core/app/components/shamir/form.js new file mode 100644 index 0000000000..27443fe96d --- /dev/null +++ b/ui/lib/core/app/components/shamir/form.js @@ -0,0 +1 @@ +export { default } from 'core/components/shamir/form'; diff --git a/ui/tests/acceptance/unseal-test.js b/ui/tests/acceptance/unseal-test.js index 8663224ef9..91b0792127 100644 --- a/ui/tests/acceptance/unseal-test.js +++ b/ui/tests/acceptance/unseal-test.js @@ -40,7 +40,7 @@ module('Acceptance | unseal', function (hooks) { // unseal for (const key of unsealKeys) { - await fillIn('[data-test-shamir-input]', key); + await fillIn('[data-test-shamir-key-input]', key); await click('button[type="submit"]'); diff --git a/ui/tests/helpers/components/shamir.js b/ui/tests/helpers/components/shamir.js new file mode 100644 index 0000000000..5b9ebc93f1 --- /dev/null +++ b/ui/tests/helpers/components/shamir.js @@ -0,0 +1,9 @@ +export const SHAMIR_FORM = { + input: '[data-test-shamir-key-input]', + inputLabel: '[data-test-shamir-key-label]', + submitButton: '[data-test-shamir-submit]', + otpInfo: '[data-test-otp-info]', + otpCode: '[data-test-otp]', + progress: '.shamir-progress', + error: '[data-test-message-error]', +}; diff --git a/ui/tests/integration/components/choose-pgp-key-form-test.js b/ui/tests/integration/components/choose-pgp-key-form-test.js new file mode 100644 index 0000000000..778d466521 --- /dev/null +++ b/ui/tests/integration/components/choose-pgp-key-form-test.js @@ -0,0 +1,79 @@ +import sinon from 'sinon'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'vault/tests/helpers'; +import { click, fillIn, render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | choose-pgp-key-form', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.set('onCancel', () => {}); + this.set('onSubmit', () => {}); + }); + + test('it renders correctly', async function (assert) { + await render( + hbs`` + ); + + assert.dom('[data-test-choose-pgp-key-form="begin"]').exists('PGP key selection form exists'); + assert + .dom('[data-test-choose-pgp-key-description]') + .hasText('my custom form text', 'uses custom form text'); + await click('[data-test-text-toggle]'); + assert.dom('[data-test-use-pgp-key-button]').isDisabled('use pgp button is disabled'); + await fillIn('[data-test-pgp-file-textarea]', 'base64-pgp-key'); + assert.dom('[data-test-use-pgp-key-button]').isNotDisabled('use pgp button is no longer disabled'); + await click('[data-test-use-pgp-key-button]'); + assert + .dom('[data-test-pgp-key-confirm]') + .hasText( + 'Below is the base-64 encoded PGP Key that will be used. Click the "Do it" button to proceed.', + 'Incorporates button text in confirmation' + ); + assert.dom('[data-test-pgp-key-copy]').hasText('base64-pgp-key', 'Shows PGP key contents'); + assert.dom('[data-test-confirm-pgp-key-submit]').hasText('Do it', 'uses passed buttonText'); + await click('[data-test-confirm-pgp-key-submit]'); + }); + test('it calls onSubmit correctly', async function (assert) { + const submitSpy = sinon.spy(); + this.set('onSubmit', submitSpy); + await render( + hbs`` + ); + + assert.dom('[data-test-choose-pgp-key-form="begin"]').exists('PGP key selection form exists'); + assert + .dom('[data-test-choose-pgp-key-description]') + .hasText('Choose a PGP Key from your computer or paste the contents of one in the form below.'); + await click('[data-test-text-toggle]'); + assert.dom('[data-test-use-pgp-key-button]').isDisabled('use pgp button is disabled'); + await fillIn('[data-test-pgp-file-textarea]', 'base64-pgp-key'); + assert.dom('[data-test-use-pgp-key-button]').isNotDisabled('use pgp button is no longer disabled'); + await click('[data-test-use-pgp-key-button]'); + assert + .dom('[data-test-pgp-key-confirm]') + .hasText( + 'Below is the base-64 encoded PGP Key that will be used. Click the "Submit" button to proceed.', + 'Confirmation text has buttonText' + ); + assert.dom('[data-test-pgp-key-copy]').hasText('base64-pgp-key', 'Shows PGP key contents'); + assert.dom('[data-test-confirm-pgp-key-submit]').hasText('Submit', 'uses passed buttonText'); + await click('[data-test-confirm-pgp-key-submit]'); + assert.ok(submitSpy.calledOnceWith('base64-pgp-key')); + }); + + test('it calls cancel on cancel', async function (assert) { + const cancelSpy = sinon.spy(); + this.set('onCancel', cancelSpy); + await render( + hbs`` + ); + + await click('[data-test-text-toggle]'); + await fillIn('[data-test-pgp-file-textarea]', 'base64-pgp-key'); + await click('[data-test-use-pgp-key-cancel]'); + assert.ok(cancelSpy.calledOnce); + }); +}); diff --git a/ui/tests/integration/components/shamir-flow-test.js b/ui/tests/integration/components/shamir-flow-test.js deleted file mode 100644 index d1b211c622..0000000000 --- a/ui/tests/integration/components/shamir-flow-test.js +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import { run } from '@ember/runloop'; -import Service from '@ember/service'; -import { resolve } from 'rsvp'; -import { module, test } from 'qunit'; -import { setupRenderingTest } from 'ember-qunit'; -import { render, click } from '@ember/test-helpers'; -import hbs from 'htmlbars-inline-precompile'; - -const response = { - progress: 1, - required: 3, - complete: false, -}; - -const adapter = { - foo() { - return resolve(response); - }, -}; - -const storeStub = Service.extend({ - adapterFor() { - return adapter; - }, -}); - -module('Integration | Component | shamir flow', function (hooks) { - setupRenderingTest(hooks); - - hooks.beforeEach(function () { - this.foo = function () {}; - run(() => { - this.owner.unregister('service:store'); - this.owner.register('service:store', storeStub); - this.storeService = this.owner.lookup('service:store'); - }); - }); - - test('it renders', async function (assert) { - await render(hbs``); - - assert.dom('form [data-test-form-text]').hasText('like whoa', 'renders formText inline'); - - await render(hbs` - -

whoa again

-
- `); - - assert.dom('.shamir-progress').doesNotExist('renders no progress bar for no progress'); - assert.dom('form [data-test-form-text]').hasText('whoa again', 'renders the block, not formText'); - - await render(hbs` - - `); - assert.dom('.shamir-progress').hasText('1/5 keys provided', 'displays textual progress'); - - this.set('errors', ['first error', 'this is fine']); - await render(hbs` - - `); - assert.dom('[data-test-message-error]').exists({ count: 2 }, 'renders errors'); - }); - - test('it sends data to the passed action', async function (assert) { - this.set('key', 'foo'); - await render(hbs` - - `); - await click('[data-test-shamir-submit]'); - assert - .dom('.shamir-progress') - .hasText(`${response.progress}/${response.required} keys provided`, 'displays the correct progress'); - }); - - test('it checks onComplete to call onShamirSuccess', async function (assert) { - assert.expect(2); - this.set('key', 'foo'); - this.set('onSuccess', function () { - assert.ok(true, 'onShamirSuccess called'); - }); - - this.set('checkComplete', function () { - assert.ok(true, 'onComplete called'); - // return true so we trigger success call - return true; - }); - - await render(hbs` - - `); - await click('[data-test-shamir-submit]'); - }); - - test('it fetches progress on init when fetchOnInit is true', async function (assert) { - await render(hbs` - - `); - assert - .dom('.shamir-progress') - .hasText(`${response.progress}/${response.required} keys provided`, 'displays the correct progress'); - }); -}); diff --git a/ui/tests/integration/components/shamir-modal-flow-test.js b/ui/tests/integration/components/shamir-modal-flow-test.js deleted file mode 100644 index 6b4c773101..0000000000 --- a/ui/tests/integration/components/shamir-modal-flow-test.js +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import { module, test, skip } from 'qunit'; -import { setupRenderingTest } from 'ember-qunit'; -import { render } from '@ember/test-helpers'; -import hbs from 'htmlbars-inline-precompile'; -import sinon from 'sinon'; -import { setupMirage } from 'ember-cli-mirage/test-support'; - -module('Integration | Component | shamir-modal-flow', function (hooks) { - setupRenderingTest(hooks); - setupMirage(hooks); - - hooks.beforeEach(function () { - this.set('isActive', true); - this.set('onClose', sinon.spy()); - this.server.get('/sys/replication/dr/secondary/generate-operation-token/attempt', () => {}); - }); - - test('it renders with initial content by default', async function (assert) { - await render(hbs` - - -

Inner content goes here

-
- `); - - assert - .dom('[data-test-shamir-modal-body]') - .hasText('Inner content goes here', 'Template block gets rendered'); - assert.dom('[data-test-shamir-modal-cancel-button]').hasText('Cancel', 'Shows cancel button'); - }); - - test('Shows correct content when started', async function (assert) { - await render(hbs` - - -

Inner content goes here

-
- `); - assert.dom('[data-test-shamir-input]').exists('Asks for root key Portion'); - assert.dom('[data-test-shamir-modal-cancel-button]').hasText('Cancel', 'Shows cancel button'); - }); - - test('Shows OTP when provided and flow started', async function (assert) { - await render(hbs` - - -

Inner content goes here

-
- `); - assert.dom('[data-test-shamir-encoded-token]').hasText('my-encoded-token', 'Shows encoded token'); - assert.dom('[data-test-shamir-modal-cancel-button]').hasText('Close', 'Shows close button'); - }); - skip('DR Secondary actions', async function () { - // DR Secondaries cannot be tested yet, but once they can - // we should add tests for Cancel button functionality - }); -}); diff --git a/ui/tests/integration/components/shamir/dr-token-flow-test.js b/ui/tests/integration/components/shamir/dr-token-flow-test.js new file mode 100644 index 0000000000..4a4acc97f0 --- /dev/null +++ b/ui/tests/integration/components/shamir/dr-token-flow-test.js @@ -0,0 +1,217 @@ +import sinon from 'sinon'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'vault/tests/helpers'; +import { click, fillIn, render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupMirage } from 'ember-cli-mirage/test-support'; + +module('Integration | Component | shamir/dr-token-flow', function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + + test('begin to middle flow works', async function (assert) { + assert.expect(15); + this.server.get('/sys/replication/dr/secondary/generate-operation-token/attempt', function () { + assert.ok('Check endpoint is queried on init'); + return {}; + }); + this.server.post('/sys/replication/dr/secondary/generate-operation-token/attempt', function (_, req) { + const requestBody = JSON.parse(req.requestBody); + assert.ok('Starts the token generation'); + assert.deepEqual(requestBody, { attempt: true }); + return { + started: true, + nonce: 'nonce-1234', + progress: 0, + required: 3, + encoded_token: '', + otp: 'otp-9876', + otp_length: 24, + complete: false, + }; + }); + this.server.post('/sys/replication/dr/secondary/generate-operation-token/update', function (_, req) { + const requestBody = JSON.parse(req.requestBody); + assert.ok('Makes request at the /update path'); + assert.deepEqual(requestBody, { key: 'some-key', nonce: 'nonce-1234' }); + return { + started: true, + nonce: 'nonce-1234', + progress: 1, + required: 3, + encoded_token: '', + otp: '', + otp_length: 24, + complete: false, + }; + }); + await render(hbs``); + assert.dom('[data-test-dr-token-flow-step="begin"]').exists('First step shows'); + assert.dom('[data-test-use-pgp-key-cta]').hasText('Provide PGP Key'); + assert.dom('[data-test-generate-token-cta]').hasText('Generate operation token'); + await click('[data-test-generate-token-cta]'); + assert.dom('[data-test-dr-token-flow-step="shamir"]').exists('Shows shamir step after start'); + assert + .dom('.shamir-progress') + .hasText('0/3 keys provided', 'progress shows reflecting checkStatus response with defaults'); + assert.dom('[data-test-otp-info]').exists('OTP info banner shows'); + assert.dom('[data-test-otp]').hasText('otp-9876', 'Shows OTP in copy banner'); + // Fill in shamir key and submit + await fillIn('[data-test-shamir-key-input]', 'some-key'); + await click('[data-test-shamir-submit]'); + assert.dom('.shamir-progress').hasText('1/3 keys provided', 'progress shows reflecting attempt response'); + assert + .dom('[data-test-otp-info]') + .exists('OTP info still banner shows even when attempt response does not include it'); + assert + .dom('[data-test-otp]') + .hasText('otp-9876', 'Still shows OTP in copy banner when attempt response does not include it'); + }); + + test('middle to finish flow works', async function (assert) { + assert.expect(9); + this.server.get('/sys/replication/dr/secondary/generate-operation-token/attempt', function () { + assert.ok('Check endpoint is queried on init'); + return { + started: true, + nonce: 'nonce-1234', + progress: 2, + required: 3, + encoded_token: '', + otp: '', + otp_length: 24, + complete: false, + }; + }); + this.server.post('/sys/replication/dr/secondary/generate-operation-token/attempt', function () { + assert.notOk('attempt endpoint should not be queried'); + }); + this.server.post('/sys/replication/dr/secondary/generate-operation-token/update', function (_, req) { + const requestBody = JSON.parse(req.requestBody); + assert.ok('Makes request at the /update path'); + assert.deepEqual(requestBody, { key: 'some-key', nonce: 'nonce-1234' }); + return { + started: true, + nonce: 'nonce-1234', + progress: 3, + required: 3, + encoded_token: 'encoded-token-here', + otp: '', + otp_length: 24, + complete: true, + }; + }); + await render(hbs``); + assert.dom('[data-test-dr-token-flow-step="shamir"]').exists('Shows shamir step on load'); + assert + .dom('.shamir-progress') + .hasText('2/3 keys provided', 'progress shows reflecting checkStatus response'); + assert.dom('[data-test-otp-info]').doesNotExist('OTP info banner not shown'); + assert.dom('[data-test-otp]').doesNotExist('otp-9876', 'OTP copy banner not shown'); + await fillIn('[data-test-shamir-key-input]', 'some-key'); + await click('[data-test-shamir-submit]'); + assert + .dom('[data-test-dr-token-flow-step="show-token"]') + .exists('updates to show encoded token on complete'); + assert + .dom('[data-test-shamir-encoded-token]') + .hasText('encoded-token-here', 'shows encoded token from /update response'); + }); + + test('it works correctly when pgp key chosen', async function (assert) { + assert.expect(3); + this.server.get('/sys/replication/dr/secondary/generate-operation-token/attempt', function () { + return {}; + }); + this.server.post( + '/sys/replication/dr/secondary/generate-operation-token/attempt', + function (schema, req) { + const body = JSON.parse(req.requestBody); + assert.deepEqual(body, { pgp_key: 'some-key-here' }, 'correct payload'); + return { + started: true, + progress: 1, + required: 3, + complete: false, + }; + } + ); + await render(hbs``); + await click('[data-test-use-pgp-key-cta]'); + assert.dom('[data-test-choose-pgp-key-form="begin"]').exists('PGP form shows'); + await click('[data-test-text-toggle]'); + await fillIn('[data-test-pgp-file-textarea]', 'some-key-here'); + await click('[data-test-use-pgp-key-button]'); + await click('[data-test-confirm-pgp-key-submit]'); + assert.dom('[data-test-dr-token-flow-step="shamir"]').exists('Renders shamir step after PGP key chosen'); + }); + + test('it cancels correctly when generation not started', async function (assert) { + assert.expect(2); + const cancelSpy = sinon.spy(); + this.set('onCancel', cancelSpy); + this.server.get('/sys/replication/dr/secondary/generate-operation-token/attempt', function () { + return {}; + }); + this.server.delete('/sys/replication/dr/secondary/generate-operation-token/attempt', function () { + assert.notOk('delete endpoint should not be queried'); + return {}; + }); + await render( + hbs`` + ); + + assert.dom('[data-test-shamir-modal-cancel-button]').hasText('Cancel', 'Close button has correct copy'); + await click('[data-test-shamir-modal-cancel-button]'); + assert.ok(cancelSpy.calledOnce, 'cancel spy called on click'); + }); + test('it cancels correctly when generation has started but not finished', async function (assert) { + assert.expect(3); + const cancelSpy = sinon.spy(); + this.set('onCancel', cancelSpy); + this.server.get('/sys/replication/dr/secondary/generate-operation-token/attempt', function () { + return { + started: true, + progress: 1, + required: 3, + complete: false, + }; + }); + this.server.delete('/sys/replication/dr/secondary/generate-operation-token/attempt', function () { + assert.ok('delete endpoint is queried'); + return {}; + }); + await render( + hbs`` + ); + + assert.dom('[data-test-shamir-modal-cancel-button]').hasText('Cancel', 'Close button has correct copy'); + await click('[data-test-shamir-modal-cancel-button]'); + assert.ok(cancelSpy.calledOnce, 'cancel spy called on click'); + }); + test('it closes correctly when generation is completed', async function (assert) { + assert.expect(2); + const cancelSpy = sinon.spy(); + this.set('onCancel', cancelSpy); + this.server.get('/sys/replication/dr/secondary/generate-operation-token/attempt', function () { + return { + started: true, + progress: 3, + required: 3, + complete: true, + encoded_token: 'foobar', + }; + }); + this.server.delete('/sys/replication/dr/secondary/generate-operation-token/attempt', function () { + assert.notOk('delete endpoint should not be queried'); + return {}; + }); + await render( + hbs`` + ); + + assert.dom('[data-test-shamir-modal-cancel-button]').hasText('Close', 'Close button has correct copy'); + await click('[data-test-shamir-modal-cancel-button]'); + assert.ok(cancelSpy.calledOnce, 'cancel spy called on click'); + }); +}); diff --git a/ui/tests/integration/components/shamir/flow-test.js b/ui/tests/integration/components/shamir/flow-test.js new file mode 100644 index 0000000000..0601a4d68a --- /dev/null +++ b/ui/tests/integration/components/shamir/flow-test.js @@ -0,0 +1,129 @@ +import sinon from 'sinon'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'vault/tests/helpers'; +import { click, fillIn, render, settled } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import Service from '@ember/service'; +import { run } from '@ember/runloop'; +import { reject, resolve } from 'rsvp'; +import { SHAMIR_FORM } from 'vault/tests/helpers/components/shamir'; + +const licenseError = { httpStatus: 500, errors: ['failed because licensing is in an invalid state'] }; +const response = { + progress: 1, + required: 3, + complete: false, +}; + +const adapter = { + foo() { + return resolve(response); + }, + responseWithErrors() { + return reject({ httpStatus: 400, errors: ['something is wrong', 'seriously wrong'] }); + }, + responseWithLicense() { + return reject(licenseError); + }, +}; + +const storeStub = Service.extend({ + adapterFor() { + return adapter; + }, +}); + +// Checks that the correct data were passed around happens in the integration test +// this one is checking that things happen at the right time +module('Integration | Component | shamir/flow', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.keyPart = 'some-key-partition'; + run(() => { + this.owner.unregister('service:store'); + this.owner.register('service:store', storeStub); + this.storeService = this.owner.lookup('service:store'); + }); + }); + + test('it sends data to the passed action and calls updateProgress', async function (assert) { + const updateSpy = sinon.spy(); + const completeSpy = sinon.spy(); + this.set('updateProgress', updateSpy); + this.set('checkComplete', () => false); + this.set('onSuccess', completeSpy); + this.set('progress', 0); + + await render(hbs` + `); + + await fillIn(SHAMIR_FORM.input, this.keyPart); + await click(SHAMIR_FORM.submitButton); + + assert.ok(completeSpy.notCalled, 'onShamirSuccess was not called'); + assert.ok(updateSpy.calledOnce, 'updateProgress was called'); + // Default shamir flow expects the updated values to be passed + // in from parent model, so this approximates the update happening + // from a side effect of the updateProgress call + this.set('progress', 2); + // Pretend the next call will mean completion + this.set('checkComplete', () => true); + await settled(); + + await fillIn(SHAMIR_FORM.input, this.keyPart); + await click(SHAMIR_FORM.submitButton); + + assert.ok(completeSpy.calledOnce, 'onShamirSuccess was called'); + assert.ok(updateSpy.calledTwice, 'updateProgress was called again'); + }); + + test('it shows the error when adapter fails with 400 httpStatus', async function (assert) { + assert.expect(3); + const updateSpy = sinon.spy(); + const completeSpy = sinon.spy(); + this.set('updateProgress', updateSpy); + this.set('checkComplete', completeSpy); + await render(hbs` + `); + + await fillIn(SHAMIR_FORM.input, this.keyPart); + await click(SHAMIR_FORM.submitButton); + assert.dom(SHAMIR_FORM.error).exists({ count: 2 }, 'renders errors'); + assert.ok(completeSpy.notCalled, 'checkComplete was not called'); + assert.ok(updateSpy.notCalled, 'updateProgress was not called'); + }); + + test.skip('it throws the error when adapter fails with license error', async function (assert) { + assert.expect(2); + try { + const licenseSpy = sinon.spy(); + this.set('onLicenseError', licenseSpy); + await render(hbs` + `); + await fillIn(SHAMIR_FORM.input, this.keyPart); + await click(SHAMIR_FORM.submitButton); + assert.ok(licenseSpy.calledOnce, 'license error triggered'); + } catch (e) { + assert.deepEqual(e, licenseError, 'throws the error'); + } + }); +}); diff --git a/ui/tests/integration/components/shamir/form-test.js b/ui/tests/integration/components/shamir/form-test.js new file mode 100644 index 0000000000..abad0ab098 --- /dev/null +++ b/ui/tests/integration/components/shamir/form-test.js @@ -0,0 +1,81 @@ +import { module, test } from 'qunit'; +import sinon from 'sinon'; +import { setupRenderingTest } from 'vault/tests/helpers'; +import { click, render, settled, typeIn } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { SHAMIR_FORM } from 'vault/tests/helpers/components/shamir'; + +module('Integration | Component | shamir/form', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.submitSpy = sinon.spy(); + }); + + test('it does calls callback only if key value present', async function (assert) { + await render(hbs` + + `); + assert.dom(SHAMIR_FORM.submitButton).hasText('Submit', 'Submit button has default text'); + await click(SHAMIR_FORM.submitButton); + assert.dom(SHAMIR_FORM.progress).doesNotExist('Hides progress bar if none made'); + assert.ok(this.submitSpy.notCalled, 'onSubmit was not called'); + await typeIn(SHAMIR_FORM.input, 'this-is-the-key'); + assert.dom(SHAMIR_FORM.input).hasValue('this-is-the-key', 'input value set'); + assert.dom(SHAMIR_FORM.inputLabel).hasText('Shamir key portion', 'label has default text'); + await click(SHAMIR_FORM.submitButton); + assert.ok( + this.submitSpy.calledOnceWith({ key: 'this-is-the-key' }), + 'onSubmit called with correct params' + ); + assert.dom(SHAMIR_FORM.input).hasValue('', 'key value reset after submit'); + + await render(hbs` + +
Hello
+
+ `); + + assert.dom('[data-test-block-content]').hasText('Hello', 'renders block content'); + assert.dom(SHAMIR_FORM.submitButton).hasText('Do the thing', 'uses passed button text'); + assert.dom(SHAMIR_FORM.inputLabel).hasText('Unseal key', 'uses passed inputLabel'); + assert.dom(SHAMIR_FORM.otpInfo).doesNotExist('no OTP info shown'); + assert + .dom(SHAMIR_FORM.progress) + .hasText('0/3 keys provided', 'displays textual progress when alwaysShowProgress=true'); + }); + + test('it shows OTP info if provided', async function (assert) { + await render(hbs` + +
Hello
+
+ `); + + assert.dom(SHAMIR_FORM.otpInfo).exists('shows OTP info'); + assert.dom(SHAMIR_FORM.otpCode).hasText('this-is-otp', 'shows OTP code'); + assert.dom(SHAMIR_FORM.progress).hasText('2/4 keys provided', 'displays textual progress'); + assert.dom('[data-test-block-content]').hasText('Hello', 'renders block content'); + }); + + test('renders errors provided', async function (assert) { + this.set('errors', ['first error', 'this is fine']); + await render(hbs` + + `); + assert.dom(SHAMIR_FORM.error).exists({ count: 2 }, 'renders errors'); + + this.set('errors', []); + await settled(); + assert.dom(SHAMIR_FORM.error).doesNotExist('errors cleared'); + }); +}); diff --git a/ui/tests/unit/adapters/cluster-test.js b/ui/tests/unit/adapters/cluster-test.js index 21b6b2ad99..8eb2daca98 100644 --- a/ui/tests/unit/adapters/cluster-test.js +++ b/ui/tests/unit/adapters/cluster-test.js @@ -250,4 +250,64 @@ module('Unit | Adapter | cluster', function (hooks) { ); assert.strictEqual(method, 'GET', 'replication:dr secondary:promote method OK'); }); + + test('cluster generateDrOperationToken', function (assert) { + let url, method, options; + const adapter = this.owner.factoryFor('adapter:cluster').create({ + ajax: (...args) => { + [url, method, options] = args; + return resolve(); + }, + }); + + // Generate token progress + adapter.generateDrOperationToken({ key: 'foo' }, {}); + assert.strictEqual( + url, + '/v1/sys/replication/dr/secondary/generate-operation-token/update', + 'progress url correct' + ); + assert.strictEqual(method, 'POST', 'progress method OK'); + assert.deepEqual({ data: { key: 'foo' }, unauthenticated: true }, options, 'progress payload OK'); + + // CheckStatus / Read generation progress + adapter.generateDrOperationToken({ key: 'foo' }, { checkStatus: true }); + assert.strictEqual( + url, + '/v1/sys/replication/dr/secondary/generate-operation-token/attempt', + 'checkStatus url correct' + ); + assert.strictEqual(method, 'GET', 'checkStatus method OK'); + assert.deepEqual({ data: { key: 'foo' }, unauthenticated: true }, options, 'checkStatus options OK'); + + // Cancel generation + adapter.generateDrOperationToken({}, { cancel: true }); + assert.strictEqual( + url, + '/v1/sys/replication/dr/secondary/generate-operation-token/attempt', + 'Cancel url correct' + ); + assert.strictEqual(method, 'DELETE', 'cancel method OK'); + assert.deepEqual({ data: {}, unauthenticated: true }, options, 'cancel options OK'); + + // pgp_key + adapter.generateDrOperationToken({ pgp_key: 'yes' }, {}); + assert.strictEqual( + url, + '/v1/sys/replication/dr/secondary/generate-operation-token/attempt', + 'pgp_key url correct' + ); + assert.strictEqual(method, 'POST', 'method ok when pgp_key on data'); + assert.deepEqual({ data: { pgp_key: 'yes' }, unauthenticated: true }, options, 'pgp_key options OK'); + + // data.attempt + adapter.generateDrOperationToken({ attempt: true }, {}); + assert.strictEqual( + url, + '/v1/sys/replication/dr/secondary/generate-operation-token/attempt', + 'data.attempt url correct' + ); + assert.strictEqual(method, 'POST', 'data.attempt method OK'); + assert.deepEqual({ data: { attempt: true }, unauthenticated: true }, options, 'data.attempt options OK'); + }); });