From a8a339c0f83143c28ada8356e49a4aea5652cb31 Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Wed, 12 Jul 2023 16:54:38 -0500 Subject: [PATCH 01/23] clickable label on raft join --- ui/app/styles/components/vlt-radio.scss | 44 ----------------------- ui/app/styles/core.scss | 1 - ui/app/templates/components/raft-join.hbs | 10 +++--- 3 files changed, 4 insertions(+), 51 deletions(-) delete mode 100644 ui/app/styles/components/vlt-radio.scss diff --git a/ui/app/styles/components/vlt-radio.scss b/ui/app/styles/components/vlt-radio.scss deleted file mode 100644 index 972e63fcbcee..000000000000 --- 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 f91971a43839..e5592a4b5793 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 3b29fe7aac6d..1c35497c5a8d 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 +
From 76265e4e253e7416e4da5846cb51a4f5af0b2d83 Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Thu, 13 Jul 2023 09:32:12 -0500 Subject: [PATCH 02/23] Small adjustments on current shamir components --- ui/lib/core/addon/components/shamir-flow.js | 41 +++++++++++++++++-- .../addon/components/shamir-modal-flow.js | 2 - .../replication-action-generate-token.hbs | 1 - 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/ui/lib/core/addon/components/shamir-flow.js b/ui/lib/core/addon/components/shamir-flow.js index 7346078493f6..1073eb7a62aa 100644 --- a/ui/lib/core/addon/components/shamir-flow.js +++ b/ui/lib/core/addon/components/shamir-flow.js @@ -4,13 +4,45 @@ */ import { inject as service } from '@ember/service'; -import { gt } from '@ember/object/computed'; +import { equal, 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'; +/* 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 +} + +unseal response (progress) +{ + "sealed": true, + "t": 3, + "n": 5, + "progress": 2, + "version": "0.6.2" +} + +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" +} +*/ const pgpKeyFileDefault = () => ({ value: '' }); const DEFAULTS = { key: null, @@ -33,7 +65,6 @@ export default Component.extend(DEFAULTS, { fetchOnInit: false, buttonText: 'Submit', thresholdPath: 'required', - generateAction: false, layout, init() { @@ -45,7 +76,7 @@ export default Component.extend(DEFAULTS, { didInsertElement() { this._super(...arguments); - this.onUpdate(this.getProperties(Object.keys(DEFAULTS))); + this.onUpdate(); }, onUpdate() {}, @@ -84,7 +115,7 @@ export default Component.extend(DEFAULTS, { delete props.otp; } this.setProperties(props); - onUpdate(props); + onUpdate(); if (isComplete(props)) { this.reset(); onShamirSuccess(props); @@ -104,6 +135,8 @@ export default Component.extend(DEFAULTS, { } }, + generateAction: equal('action', 'generate-dr-operation-token'), + generateStep: computed('generateWithPGP', 'haveSavedPGPKey', 'pgp_key', function () { const { generateWithPGP, pgp_key, haveSavedPGPKey } = this; if (!generateWithPGP && !pgp_key) { diff --git a/ui/lib/core/addon/components/shamir-modal-flow.js b/ui/lib/core/addon/components/shamir-modal-flow.js index a36f039b129d..003c7262cf26 100644 --- a/ui/lib/core/addon/components/shamir-modal-flow.js +++ b/ui/lib/core/addon/components/shamir-modal-flow.js @@ -14,13 +14,11 @@ * ``` * @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() { 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 2c6c1bfb1f09..59dc31d70398 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 @@ -24,7 +24,6 @@ @action="generate-dr-operation-token" @buttonText="Generate token" @fetchOnInit={{true}} - @generateAction={{true}} @onClose={{action (mut this.isModalActive) false}} @isActive={{this.isModalActive}} > From c51282678034313652f82f028bb27667472975d3 Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Thu, 13 Jul 2023 12:45:55 -0500 Subject: [PATCH 03/23] Add shamir/form with stub test --- ui/lib/core/addon/components/shamir/form.hbs | 54 ++++++++++++++++++ ui/lib/core/addon/components/shamir/form.js | 57 +++++++++++++++++++ ui/lib/core/app/components/shamir/form.js | 1 + .../components/shamir/form-test.js | 26 +++++++++ 4 files changed, 138 insertions(+) create mode 100644 ui/lib/core/addon/components/shamir/form.hbs create mode 100644 ui/lib/core/addon/components/shamir/form.js create mode 100644 ui/lib/core/app/components/shamir/form.js create mode 100644 ui/tests/integration/components/shamir/form-test.js 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 000000000000..8f1e96dfd78c --- /dev/null +++ b/ui/lib/core/addon/components/shamir/form.hbs @@ -0,0 +1,54 @@ +
+ {{#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}} + {{else if @formText}} + {{@formText}} + {{/if}} +
+
+ +
+ +
+
+
+
+ +
+
+ {{#if this.hasProgress}} + + {{/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 000000000000..c188d299b443 --- /dev/null +++ b/ui/lib/core/addon/components/shamir/form.js @@ -0,0 +1,57 @@ +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 + * @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} formText - text that renders on the form if no block provided + * @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 hasProgress() { + return this.args.progress > 0; + } + 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 + // If this method throws an error, we want it to throw + await this.args.onSubmit({ key }); + this.resetForm(); + } +} 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 000000000000..27443fe96d50 --- /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/integration/components/shamir/form-test.js b/ui/tests/integration/components/shamir/form-test.js new file mode 100644 index 000000000000..50a2438856dd --- /dev/null +++ b/ui/tests/integration/components/shamir/form-test.js @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'vault/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | shamir/form', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.set('myAction', function(val) { ... }); + + await render(hbs``); + + assert.dom(this.element).hasText(''); + + // Template block usage: + await render(hbs` + + template block text + + `); + + assert.dom(this.element).hasText('template block text'); + }); +}); From c3ca3bb1b1185845a0543c9630659d212ff74d27 Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Thu, 13 Jul 2023 13:33:27 -0500 Subject: [PATCH 04/23] Add WIP shamir/flow component which works for unseal action --- ui/lib/core/addon/components/shamir/flow.hbs | 8 + ui/lib/core/addon/components/shamir/flow.js | 209 ++++++++++++++++++ ui/lib/core/app/components/shamir/flow.js | 1 + .../components/shamir/flow-test.js | 26 +++ 4 files changed, 244 insertions(+) create mode 100644 ui/lib/core/addon/components/shamir/flow.hbs create mode 100644 ui/lib/core/addon/components/shamir/flow.js create mode 100644 ui/lib/core/app/components/shamir/flow.js create mode 100644 ui/tests/integration/components/shamir/flow-test.js 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 000000000000..93efad69376e --- /dev/null +++ b/ui/lib/core/addon/components/shamir/flow.hbs @@ -0,0 +1,8 @@ + \ 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 000000000000..cc635a05de45 --- /dev/null +++ b/ui/lib/core/addon/components/shamir/flow.js @@ -0,0 +1,209 @@ +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'; + +const oldArgs = { + action: 'unseal', + onLicenseError: () => {}, + onShamirSuccess: () => {}, + onUpdate: () => {}, + buttonText: 'Submit', + thresholdPath: 't', + isComplete: () => {}, + threshold: 1, + progress: 1, + fetchOnInit: false, +}; + +/** + * @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} 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. + * + */ +export default class ShamirFlowComponent extends Component { + @service store; + + @tracked errors = A(); + @tracked haveSavedPGPKey = false; + @tracked attemptResponse = null; + @tracked otp = ''; + + // get encoded_token() { + // // encoded token is returned from generate-operation-token endpoint + // return this.attemptProgress.encoded_token; + // } + // get otp() { + // // otp is returned from generate-operation-token endpoint + // return this.attemptProgress.otp; + // } + 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) { + const action = this.action; + const adapter = this.store.adapterFor('cluster'); + const method = adapter[action]; + // TODO: pass checkStatus for options + try { + const resp = await method.call(adapter, data); + this.updateProgress(resp); + this.checkComplete(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.onLicenseError(); + } + throw e; + } + } + } + + /** + * 3. This method is a hook to make updates to the display. + * 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; + 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; + } + return; + } + + /** + * 4. checkComplete checks the payload for completeness. + * For custom logic, define @checkComplete which receives + * the adapter payload. If true, @onShamirSuccess will be called + * @param {payload} response from the adapter method + * @returns void + */ + checkComplete(response) { + let isComplete = response.complete === true; + if (this.args.checkComplete) { + isComplete = this.args.checkComplete(response); + } + if (isComplete) { + this.reset(); + this.args.onShamirSuccess(); + } + return; + } + + reset() { + this.attemptResponse = null; + this.haveSavedPGPKey = false; + this.errors = null; + } + + @action + onSubmit(data) { + this.errors = null; + this.attemptProgress(this.extractData(data)); + } + + // @action + // startGenerate(data) { + // if (this.generateAction) { + // data.attempt = true; + // } + // this.attemptProgress(this.extractData(data)); + // } +} + +/* 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 +} + +unseal response (progress) +{ + "sealed": true, + "t": 3, + "n": 5, + "progress": 2, + "version": "0.6.2" +} + +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/app/components/shamir/flow.js b/ui/lib/core/app/components/shamir/flow.js new file mode 100644 index 000000000000..23fee295b1af --- /dev/null +++ b/ui/lib/core/app/components/shamir/flow.js @@ -0,0 +1 @@ +export { default } from 'core/components/shamir/flow'; \ No newline at end of file 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 000000000000..d0cd8b0d50cc --- /dev/null +++ b/ui/tests/integration/components/shamir/flow-test.js @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'vault/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | shamir/flow', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.set('myAction', function(val) { ... }); + + await render(hbs``); + + assert.dom(this.element).hasText(''); + + // Template block usage: + await render(hbs` + + template block text + + `); + + assert.dom(this.element).hasText('template block text'); + }); +}); From d6b4eb5c65210f1af74b54a360ba48228aa1128f Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Thu, 13 Jul 2023 13:35:09 -0500 Subject: [PATCH 05/23] Use new components on unseal flow --- ui/app/controllers/vault/cluster/unseal.js | 4 +++ ui/app/templates/vault/cluster/unseal.hbs | 36 +++++++++++++--------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/ui/app/controllers/vault/cluster/unseal.js b/ui/app/controllers/vault/cluster/unseal.js index a59fcde85645..79fcf26b4b64 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/templates/vault/cluster/unseal.hbs b/ui/app/templates/vault/cluster/unseal.hbs index 98f9598c6327..a239f3a62c00 100644 --- a/ui/app/templates/vault/cluster/unseal.hbs +++ b/ui/app/templates/vault/cluster/unseal.hbs @@ -31,21 +31,29 @@ 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}} + {{#unless this.model.unsealed}} + + {{/unless}}
-
From 42818d3e5a67c41dd90f41849a1f187a998c6406 Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Fri, 14 Jul 2023 09:58:27 -0500 Subject: [PATCH 06/23] WIP --- ui/app/adapters/cluster.js | 3 +- .../components/shamir-modal-flow.hbs | 41 ++++--- .../addon/components/shamir-modal-flow.js | 106 ++++++++++++++---- ui/lib/core/addon/components/shamir/form.js | 4 +- 4 files changed, 115 insertions(+), 39 deletions(-) rename ui/lib/core/addon/{templates => }/components/shamir-modal-flow.hbs (87%) diff --git a/ui/app/adapters/cluster.js b/ui/app/adapters/cluster.js index c750b6596ba5..a0d711cdae68 100644 --- a/ui/app/adapters/cluster.js +++ b/ui/app/adapters/cluster.js @@ -211,12 +211,13 @@ export default ApplicationAdapter.extend({ }, generateDrOperationToken(data, options) { + console.log({ data, options }); let verb = options && options.checkStatus ? 'GET' : 'PUT'; if (options.cancel) { verb = 'DELETE'; } let url = `${this.buildURL()}/replication/dr/secondary/generate-operation-token/`; - if (!data || data.pgp_key || data.attempt) { + if (!options.cancel || data.pgp_key || data.attempt) { // start the generation url = url + 'attempt'; } else { diff --git a/ui/lib/core/addon/templates/components/shamir-modal-flow.hbs b/ui/lib/core/addon/components/shamir-modal-flow.hbs similarity index 87% rename from ui/lib/core/addon/templates/components/shamir-modal-flow.hbs rename to ui/lib/core/addon/components/shamir-modal-flow.hbs index b4b4d046b9db..dd9f3cd5179e 100644 --- a/ui/lib/core/addon/templates/components/shamir-modal-flow.hbs +++ b/ui/lib/core/addon/components/shamir-modal-flow.hbs @@ -1,11 +1,18 @@
-
- {{else if (and this.generateAction (not this.started))}} -
+ {{else if (not this.started)}} + {{#if (eq this.generateStep "chooseMethod")}} + HERE HERE HERE
{{yield}}
-
@@ -105,16 +117,16 @@ 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.

- +
-
-
@@ -132,18 +144,18 @@ {{this.pgpKeyFile.filename}}
- - {{this.pgp_key}} + + {{this.pgpKey}}
-
-
@@ -151,7 +163,8 @@ {{/if}} {{else}} -
+ else else +
{{#if this.errors}}
diff --git a/ui/lib/core/addon/components/shamir-modal-flow.js b/ui/lib/core/addon/components/shamir-modal-flow.js index 003c7262cf26..4b68ca76795c 100644 --- a/ui/lib/core/addon/components/shamir-modal-flow.js +++ b/ui/lib/core/addon/components/shamir-modal-flow.js @@ -14,29 +14,91 @@ * ``` * @param {function} onClose - This function will be triggered when the modal intends to be closed */ -import ShamirFlow from './shamir-flow'; -import layout from '../templates/components/shamir-modal-flow'; - -export default ShamirFlow.extend({ - layout, - onClose: () => {}, - actions: { - onCancelClose() { - if (this.encoded_token) { - this.send('reset'); - } else if (this.generateAction && !this.started) { - if (this.generateStep !== 'chooseMethod') { - this.send('reset'); +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import ShamirFlow from './shamir/flow'; + +const pgpKeyFileDefault = () => ({ value: '' }); +export default class ShamirModalFlow extends ShamirFlow { + @tracked started = false; + @tracked generateAction = ''; + @tracked generateWithPGP = false; + @tracked haveSavedPGPKey = false; + @tracked pgpKeyFile = pgpKeyFileDefault(); + + constructor() { + super(...arguments); + this.startGenerate(); + } + + async startGenerate(data, evt) { + console.log({ data, evt }); + const action = this.action; + const adapter = this.store.adapterFor('cluster'); + const method = adapter[action]; + try { + const resp = await method.call(adapter, {}, { checkStatus: true }); + this.updateProgress(resp); + this.checkComplete(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.onLicenseError(); } + throw e; + } + } + } + + get generateAction() { + // TODO: this is redundant, this component is specific to this action + return this.args.action === 'generate-dr-operation-token'; + } + + get generateStep() { + const { generateWithPGP, attemptResponse, haveSavedPGPKey } = this; + if (!generateWithPGP && !attemptResponse?.pgp_key) { + return 'chooseMethod'; + } + if (generateWithPGP) { + if (attemptResponse?.pgp_key && haveSavedPGPKey) { + return 'beginGenerationWithPGP'; } else { - const adapter = this.store.adapterFor('cluster'); - adapter.generateDrOperationToken(this.model, { cancel: true }); + return 'providePGPKey'; + } + } + return ''; + } + get encoded_token() { + return this.attemptResponse?.encoded_token; + } + get started() { + return this.attemptResponse?.started; + } + + @action setKey(_, keyFile) { + this.pgpKey = keyFile.value; + this.pgpKeyFile = keyFile; + } + + @action + onCancelClose() { + if (this.attemptResponse.encoded_token) { + this.send('reset'); + } else if (this.generateAction && !this.started) { + if (this.generateStep !== 'chooseMethod') { this.send('reset'); } - this.onClose(); - }, - onClose() { - this.onClose(); - }, - }, -}); + } else { + const adapter = this.store.adapterFor('cluster'); + adapter.generateDrOperationToken({}, { cancel: true }); + this.send('reset'); + } + this.args.onClose(); + } +} diff --git a/ui/lib/core/addon/components/shamir/form.js b/ui/lib/core/addon/components/shamir/form.js index c188d299b443..82c983dccbc6 100644 --- a/ui/lib/core/addon/components/shamir/form.js +++ b/ui/lib/core/addon/components/shamir/form.js @@ -10,7 +10,7 @@ import { tracked } from '@glimmer/tracking'; * * @example * ```js - * * ``` * - * @param {Function} onSubmit - method which handles the action for shamir + * @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" From 5a0130819cdf6f5fecaae663c1b86b0a0cd4eab9 Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Fri, 14 Jul 2023 12:25:59 -0500 Subject: [PATCH 07/23] Add ChoosePgpKeyForm component for selecting PGP key --- .../addon/components/choose-pgp-key-form.hbs | 52 +++++++++++++++++++ .../addon/components/choose-pgp-key-form.js | 40 ++++++++++++++ .../app/components/choose-pgp-key-form.js | 1 + .../components/choose-pgp-key-form-test.js | 26 ++++++++++ 4 files changed, 119 insertions(+) create mode 100644 ui/lib/core/addon/components/choose-pgp-key-form.hbs create mode 100644 ui/lib/core/addon/components/choose-pgp-key-form.js create mode 100644 ui/lib/core/app/components/choose-pgp-key-form.js create mode 100644 ui/tests/integration/components/choose-pgp-key-form-test.js 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 000000000000..7cebce7b3e61 --- /dev/null +++ b/ui/lib/core/addon/components/choose-pgp-key-form.hbs @@ -0,0 +1,52 @@ +{{#if this.selectedPgp}} + +
+

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

+

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

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

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

+ +
+
+
+ +
+
+ +
+
+
+{{/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 000000000000..205cdad47d02 --- /dev/null +++ b/ui/lib/core/addon/components/choose-pgp-key-form.js @@ -0,0 +1,40 @@ +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 - This function will be triggered when the modal intends to be closed + * @param {function} onSubmit - When the PGP key is confirmed, it will call this method with the pgpKey value as the only param + */ +export default class ChoosePgpKeyForm extends Component { + @tracked pgpKeyFile = pgpKeyFileDefault(); + @tracked selectedPgp = ''; + + get pgpKey() { + return this.pgpKeyFile.value; + } + + @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/app/components/choose-pgp-key-form.js b/ui/lib/core/app/components/choose-pgp-key-form.js new file mode 100644 index 000000000000..68286c9d9c6e --- /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/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 000000000000..21c25826c930 --- /dev/null +++ b/ui/tests/integration/components/choose-pgp-key-form-test.js @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'vault/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | choose-pgp-key-form', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.set('myAction', function(val) { ... }); + + await render(hbs``); + + assert.dom(this.element).hasText(''); + + // Template block usage: + await render(hbs` + + template block text + + `); + + assert.dom(this.element).hasText('template block text'); + }); +}); From f16bf04ee912bcfbc785d093e1b0ec4857085610 Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Fri, 14 Jul 2023 15:10:20 -0500 Subject: [PATCH 08/23] Add Shamir/DrTokenFlow component and updates to Shamir/Flow which it extends --- .../addon/components/shamir/dr-token-flow.hbs | 122 ++++++++++++++++++ .../addon/components/shamir/dr-token-flow.js | 120 +++++++++++++++++ ui/lib/core/addon/components/shamir/flow.hbs | 2 +- ui/lib/core/addon/components/shamir/flow.js | 95 ++++---------- .../app/components/shamir/dr-token-flow.js | 1 + .../components/shamir/dr-token-flow-test.js | 26 ++++ 6 files changed, 296 insertions(+), 70 deletions(-) create mode 100644 ui/lib/core/addon/components/shamir/dr-token-flow.hbs create mode 100644 ui/lib/core/addon/components/shamir/dr-token-flow.js create mode 100644 ui/lib/core/app/components/shamir/dr-token-flow.js create mode 100644 ui/tests/integration/components/shamir/dr-token-flow-test.js 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 000000000000..7b875d82c29d --- /dev/null +++ b/ui/lib/core/addon/components/shamir/dr-token-flow.hbs @@ -0,0 +1,122 @@ + +
+ +
+{{! }} \ 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 000000000000..eff51052845b --- /dev/null +++ b/ui/lib/core/addon/components/shamir/dr-token-flow.js @@ -0,0 +1,120 @@ +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; + } + + // 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.encoded_token) { + 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 index 93efad69376e..74e2a95cf689 100644 --- a/ui/lib/core/addon/components/shamir/flow.hbs +++ b/ui/lib/core/addon/components/shamir/flow.hbs @@ -1,5 +1,5 @@ {}, - onShamirSuccess: () => {}, - onUpdate: () => {}, - buttonText: 'Submit', - thresholdPath: 't', - isComplete: () => {}, - threshold: 1, - progress: 1, - fetchOnInit: false, -}; - /** * @module ShamirFlowComponent * These components are used to manage keeping track of a shamir unseal flow. @@ -50,20 +37,9 @@ const oldArgs = { */ export default class ShamirFlowComponent extends Component { @service store; - @tracked errors = A(); - @tracked haveSavedPGPKey = false; @tracked attemptResponse = null; - @tracked otp = ''; - // get encoded_token() { - // // encoded token is returned from generate-operation-token endpoint - // return this.attemptProgress.encoded_token; - // } - // get otp() { - // // otp is returned from generate-operation-token endpoint - // return this.attemptProgress.otp; - // } get action() { if (!this.args.action) return ''; return camelize(this.args.action); @@ -90,14 +66,17 @@ export default class ShamirFlowComponent extends Component { * @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]; - // TODO: pass checkStatus for options + // Only used for DR token generate + const checkStatus = data ? false : true; + try { - const resp = await method.call(adapter, data); + const resp = await method.call(adapter, data, { checkStatus }); this.updateProgress(resp); - this.checkComplete(resp); + this.handleComplete(resp); return; } catch (e) { if (e.httpStatus === 400) { @@ -114,7 +93,7 @@ export default class ShamirFlowComponent extends Component { } /** - * 3. This method is a hook to make updates to the display. + * 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. @@ -126,68 +105,46 @@ export default class ShamirFlowComponent extends Component { this.args.updateProgress(); } this.attemptResponse = 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; - } return; } /** - * 4. checkComplete checks the payload for completeness. - * For custom logic, define @checkComplete which receives - * the adapter payload. If true, @onShamirSuccess will be called + * 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 */ - checkComplete(response) { - let isComplete = response.complete === true; - if (this.args.checkComplete) { - isComplete = this.args.checkComplete(response); - } + handleComplete(response) { + const isComplete = this.checkComplete(response); if (isComplete) { - this.reset(); - this.args.onShamirSuccess(); + 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.haveSavedPGPKey = false; this.errors = null; } @action - onSubmit(data) { - this.errors = null; + onSubmitKey(data) { this.attemptProgress(this.extractData(data)); } - - // @action - // startGenerate(data) { - // if (this.generateAction) { - // data.attempt = true; - // } - // this.attemptProgress(this.extractData(data)); - // } -} - -/* 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 } -unseal response (progress) +/* example unseal response (progress) { "sealed": true, "t": 3, @@ -196,7 +153,7 @@ unseal response (progress) "version": "0.6.2" } -unseal response (finished) +example unseal response (finished) { "sealed": false, "t": 3, 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 000000000000..46629e28486d --- /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/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 000000000000..017d7b351f00 --- /dev/null +++ b/ui/tests/integration/components/shamir/dr-token-flow-test.js @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'vault/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | shamir/dr-token-flow', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + // Set any properties with this.set('myProperty', 'value'); + // Handle any actions with this.set('myAction', function(val) { ... }); + + await render(hbs``); + + assert.dom(this.element).hasText(''); + + // Template block usage: + await render(hbs` + + template block text + + `); + + assert.dom(this.element).hasText('template block text'); + }); +}); From 4c749ea05de509f5649245a3fa4be2bf7a910240 Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Fri, 14 Jul 2023 15:11:52 -0500 Subject: [PATCH 09/23] Small updates to Shamir/Form --- ui/lib/core/addon/components/shamir/form.hbs | 8 +------- ui/lib/core/addon/components/shamir/form.js | 8 ++++---- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/ui/lib/core/addon/components/shamir/form.hbs b/ui/lib/core/addon/components/shamir/form.hbs index 8f1e96dfd78c..ddaa12c2dd2f 100644 --- a/ui/lib/core/addon/components/shamir/form.hbs +++ b/ui/lib/core/addon/components/shamir/form.hbs @@ -23,8 +23,6 @@ {{/if}} {{#if (has-block)}} {{yield}} - {{else if @formText}} - {{@formText}} {{/if}}
@@ -42,13 +40,9 @@
- {{#if this.hasProgress}} + {{#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 index 82c983dccbc6..d4b9279e7bbd 100644 --- a/ui/lib/core/addon/components/shamir/form.js +++ b/ui/lib/core/addon/components/shamir/form.js @@ -23,7 +23,7 @@ import { tracked } from '@glimmer/tracking'; * @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} formText - text that renders on the form if no block provided + * @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 * */ @@ -34,9 +34,10 @@ export default class ShamirFormComponent extends Component { get buttonText() { return this.args.buttonText || 'Submit'; } - get hasProgress() { - return this.args.progress > 0; + get showProgress() { + return this.args.progress > 0 || this.args.alwaysShowProgress; } + resetForm() { this.key = ''; this.loading = false; @@ -50,7 +51,6 @@ export default class ShamirFormComponent extends Component { return; } // Parent handles action and passes in errors if present - // If this method throws an error, we want it to throw await this.args.onSubmit({ key }); this.resetForm(); } From 79c4c7ef60cb0842a6ad456304bcf3691fe4b1ea Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Fri, 14 Jul 2023 15:12:57 -0500 Subject: [PATCH 10/23] Use updated Shamir components for unseal and replication generate token --- ui/app/templates/vault/cluster/unseal.hbs | 2 +- .../replication-action-generate-token.hbs | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/ui/app/templates/vault/cluster/unseal.hbs b/ui/app/templates/vault/cluster/unseal.hbs index a239f3a62c00..1e4c45f8a0c9 100644 --- a/ui/app/templates/vault/cluster/unseal.hbs +++ b/ui/app/templates/vault/cluster/unseal.hbs @@ -45,8 +45,8 @@ @onLicenseError={{action "handleLicenseError"}} @onShamirSuccess={{action "transitionToCluster"}} @updateProgress={{action "reloadCluster"}} + @checkComplete={{action "isUnsealed"}} @buttonText="Unseal" - @thresholdPath="t" @isComplete={{action "isUnsealed"}} @threshold={{this.model.sealThreshold}} @progress={{this.model.sealProgress}} 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 59dc31d70398..b17a8b441d71 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,16 +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 From ce6b34c8c6e16e3ad492cb67a2d8c6ded3c2ef5f Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Fri, 14 Jul 2023 15:14:52 -0500 Subject: [PATCH 11/23] remove old shamir components --- ui/lib/core/addon/components/shamir-flow.js | 225 ----------------- .../addon/components/shamir-modal-flow.hbs | 232 ------------------ .../addon/components/shamir-modal-flow.js | 104 -------- ui/lib/core/app/components/shamir-flow.js | 6 - .../core/app/components/shamir-modal-flow.js | 6 - .../components/shamir-flow-test.js | 108 -------- .../components/shamir-modal-flow-test.js | 88 ------- 7 files changed, 769 deletions(-) delete mode 100644 ui/lib/core/addon/components/shamir-flow.js delete mode 100644 ui/lib/core/addon/components/shamir-modal-flow.hbs delete mode 100644 ui/lib/core/addon/components/shamir-modal-flow.js delete mode 100644 ui/lib/core/app/components/shamir-flow.js delete mode 100644 ui/lib/core/app/components/shamir-modal-flow.js delete mode 100644 ui/tests/integration/components/shamir-flow-test.js delete mode 100644 ui/tests/integration/components/shamir-modal-flow-test.js 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 1073eb7a62aa..000000000000 --- a/ui/lib/core/addon/components/shamir-flow.js +++ /dev/null @@ -1,225 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ - -import { inject as service } from '@ember/service'; -import { equal, 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'; - -/* 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 -} - -unseal response (progress) -{ - "sealed": true, - "t": 3, - "n": 5, - "progress": 2, - "version": "0.6.2" -} - -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" -} -*/ -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', - layout, - - init() { - this._super(...arguments); - if (this.fetchOnInit) { - this.attemptProgress(); - } - }, - - didInsertElement() { - this._super(...arguments); - this.onUpdate(); - }, - - 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(); - 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; - } - }, - - generateAction: equal('action', 'generate-dr-operation-token'), - - 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.hbs b/ui/lib/core/addon/components/shamir-modal-flow.hbs deleted file mode 100644 index dd9f3cd5179e..000000000000 --- a/ui/lib/core/addon/components/shamir-modal-flow.hbs +++ /dev/null @@ -1,232 +0,0 @@ - - -
- -
-
\ No newline at end of file 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 4b68ca76795c..000000000000 --- a/ui/lib/core/addon/components/shamir-modal-flow.js +++ /dev/null @@ -1,104 +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 { action } from '@ember/object'; -import { tracked } from '@glimmer/tracking'; -import ShamirFlow from './shamir/flow'; - -const pgpKeyFileDefault = () => ({ value: '' }); -export default class ShamirModalFlow extends ShamirFlow { - @tracked started = false; - @tracked generateAction = ''; - @tracked generateWithPGP = false; - @tracked haveSavedPGPKey = false; - @tracked pgpKeyFile = pgpKeyFileDefault(); - - constructor() { - super(...arguments); - this.startGenerate(); - } - - async startGenerate(data, evt) { - console.log({ data, evt }); - const action = this.action; - const adapter = this.store.adapterFor('cluster'); - const method = adapter[action]; - try { - const resp = await method.call(adapter, {}, { checkStatus: true }); - this.updateProgress(resp); - this.checkComplete(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.onLicenseError(); - } - throw e; - } - } - } - - get generateAction() { - // TODO: this is redundant, this component is specific to this action - return this.args.action === 'generate-dr-operation-token'; - } - - get generateStep() { - const { generateWithPGP, attemptResponse, haveSavedPGPKey } = this; - if (!generateWithPGP && !attemptResponse?.pgp_key) { - return 'chooseMethod'; - } - if (generateWithPGP) { - if (attemptResponse?.pgp_key && haveSavedPGPKey) { - return 'beginGenerationWithPGP'; - } else { - return 'providePGPKey'; - } - } - return ''; - } - get encoded_token() { - return this.attemptResponse?.encoded_token; - } - get started() { - return this.attemptResponse?.started; - } - - @action setKey(_, keyFile) { - this.pgpKey = keyFile.value; - this.pgpKeyFile = keyFile; - } - - @action - onCancelClose() { - if (this.attemptResponse.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({}, { cancel: true }); - this.send('reset'); - } - this.args.onClose(); - } -} 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 86f7cfad4d34..000000000000 --- 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 f7e78e549b0a..000000000000 --- 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/tests/integration/components/shamir-flow-test.js b/ui/tests/integration/components/shamir-flow-test.js deleted file mode 100644 index d1b211c62272..000000000000 --- 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 6b4c77310140..000000000000 --- 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 - }); -}); From 8568325efe8c80edc6bacb65f5bb37ddc23625a4 Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Fri, 14 Jul 2023 15:57:58 -0500 Subject: [PATCH 12/23] Shamir/Form test --- ui/lib/core/addon/components/shamir/form.hbs | 12 ++-- ui/lib/core/addon/components/shamir/form.js | 4 ++ .../components/shamir/form-test.js | 63 ++++++++++++++++--- 3 files changed, 63 insertions(+), 16 deletions(-) diff --git a/ui/lib/core/addon/components/shamir/form.hbs b/ui/lib/core/addon/components/shamir/form.hbs index ddaa12c2dd2f..bfe99d718b75 100644 --- a/ui/lib/core/addon/components/shamir/form.hbs +++ b/ui/lib/core/addon/components/shamir/form.hbs @@ -6,7 +6,7 @@ {{/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 @@ -18,7 +18,7 @@

One Time Password (otp)

- {{@otp}} + {{@otp}}
{{/if}} {{#if (has-block)}} @@ -26,17 +26,17 @@ {{/if}}
-
diff --git a/ui/lib/core/addon/components/shamir/form.js b/ui/lib/core/addon/components/shamir/form.js index d4b9279e7bbd..949adf72cc42 100644 --- a/ui/lib/core/addon/components/shamir/form.js +++ b/ui/lib/core/addon/components/shamir/form.js @@ -23,6 +23,7 @@ import { tracked } from '@glimmer/tracking'; * @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 * @@ -37,6 +38,9 @@ export default class ShamirFormComponent extends Component { get showProgress() { return this.args.progress > 0 || this.args.alwaysShowProgress; } + get inputLabel() { + return this.args.inputLabel || 'Shamir key portion'; + } resetForm() { this.key = ''; diff --git a/ui/tests/integration/components/shamir/form-test.js b/ui/tests/integration/components/shamir/form-test.js index 50a2438856dd..7005ae801b63 100644 --- a/ui/tests/integration/components/shamir/form-test.js +++ b/ui/tests/integration/components/shamir/form-test.js @@ -1,26 +1,69 @@ import { module, test } from 'qunit'; +import sinon from 'sinon'; import { setupRenderingTest } from 'vault/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { click, render, typeIn } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +const selectors = { + 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]', +}; + module('Integration | Component | shamir/form', function (hooks) { setupRenderingTest(hooks); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); - - await render(hbs``); + hooks.beforeEach(function () { + this.submitSpy = sinon.spy(); + }); - assert.dom(this.element).hasText(''); + test('it does calls callback only if key value present', async function (assert) { + await render(hbs` + + `); + assert.dom(selectors.submitButton).hasText('Submit', 'Submit button has default text'); + await click(selectors.submitButton); + assert.ok(this.submitSpy.notCalled, 'onSubmit was not called'); + await typeIn(selectors.input, 'this-is-the-key'); + assert.dom(selectors.input).hasValue('this-is-the-key', 'input value set'); + assert.dom(selectors.inputLabel).hasText('Shamir key portion', 'label has default text'); + await click(selectors.submitButton); + assert.ok( + this.submitSpy.calledOnceWith({ key: 'this-is-the-key' }), + 'onSubmit called with correct params' + ); + assert.dom(selectors.input).hasValue('', 'key value reset after submit'); // Template block usage: await render(hbs` - - template block text + +
Hello
+
+ `); + + assert.dom('[data-test-block-content]').hasText('Hello', 'renders block content'); + assert.dom(selectors.submitButton).hasText('Do the thing', 'uses passed button text'); + assert.dom(selectors.inputLabel).hasText('Unseal key', 'uses passed inputLabel'); + assert.dom(selectors.otpInfo).doesNotExist('no OTP info shown'); + }); + + test('it shows OTP info if provided', async function (assert) { + await render(hbs` + +
Hello
`); - assert.dom(this.element).hasText('template block text'); + assert.dom(selectors.otpInfo).exists('shows OTP info'); + assert.dom(selectors.otpCode).hasText('this-is-otp', 'shows OTP code'); + assert.dom('[data-test-block-content]').hasText('Hello', 'renders block content'); }); }); From 74f56f9b9fa07d2f6b0f028905b02ac9fe6ae748 Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Fri, 14 Jul 2023 17:15:29 -0500 Subject: [PATCH 13/23] Move selectors to shared, Shamir::Flow tests, cleanup --- ui/app/templates/vault/cluster/unseal.hbs | 12 +- ui/lib/core/addon/components/shamir/flow.hbs | 1 + ui/lib/core/addon/components/shamir/flow.js | 4 +- ui/tests/helpers/components/shamir.js | 9 ++ .../components/shamir/flow-test.js | 127 ++++++++++++++++-- .../components/shamir/form-test.js | 56 ++++---- 6 files changed, 167 insertions(+), 42 deletions(-) create mode 100644 ui/tests/helpers/components/shamir.js diff --git a/ui/app/templates/vault/cluster/unseal.hbs b/ui/app/templates/vault/cluster/unseal.hbs index 1e4c45f8a0c9..66593b441ffe 100644 --- a/ui/app/templates/vault/cluster/unseal.hbs +++ b/ui/app/templates/vault/cluster/unseal.hbs @@ -42,14 +42,14 @@ {{#unless this.model.unsealed}} {{/unless}} diff --git a/ui/lib/core/addon/components/shamir/flow.hbs b/ui/lib/core/addon/components/shamir/flow.hbs index 74e2a95cf689..75aaf506ded1 100644 --- a/ui/lib/core/addon/components/shamir/flow.hbs +++ b/ui/lib/core/addon/components/shamir/flow.hbs @@ -3,6 +3,7 @@ @progress={{@progress}} @threshold={{@threshold}} @buttonText={{@buttonText}} + @inputLabel={{@inputLabel}} @errors={{this.errors}} ...attributes /> \ 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 index 4af309f05be8..303acaa47170 100644 --- a/ui/lib/core/addon/components/shamir/flow.js +++ b/ui/lib/core/addon/components/shamir/flow.js @@ -28,11 +28,13 @@ import { tracked } from '@glimmer/tracking'; * @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 { @@ -85,7 +87,7 @@ export default class ShamirFlowComponent extends Component { } 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(); + this.args.onLicenseError(); } throw e; } diff --git a/ui/tests/helpers/components/shamir.js b/ui/tests/helpers/components/shamir.js new file mode 100644 index 000000000000..5b9ebc93f181 --- /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/shamir/flow-test.js b/ui/tests/integration/components/shamir/flow-test.js index d0cd8b0d50cc..0601a4d68a2f 100644 --- a/ui/tests/integration/components/shamir/flow-test.js +++ b/ui/tests/integration/components/shamir/flow-test.js @@ -1,26 +1,129 @@ +import sinon from 'sinon'; import { module, test } from 'qunit'; import { setupRenderingTest } from 'vault/tests/helpers'; -import { render } from '@ember/test-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); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + 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 render(hbs` + `); - assert.dom(this.element).hasText(''); + await fillIn(SHAMIR_FORM.input, this.keyPart); + await click(SHAMIR_FORM.submitButton); - // Template block usage: + 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` - - template block text - - `); + `); + + 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'); + }); - assert.dom(this.element).hasText('template block text'); + 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 index 7005ae801b63..352e390a824d 100644 --- a/ui/tests/integration/components/shamir/form-test.js +++ b/ui/tests/integration/components/shamir/form-test.js @@ -1,16 +1,9 @@ import { module, test } from 'qunit'; import sinon from 'sinon'; import { setupRenderingTest } from 'vault/tests/helpers'; -import { click, render, typeIn } from '@ember/test-helpers'; +import { click, render, settled, typeIn } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; - -const selectors = { - 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]', -}; +import { SHAMIR_FORM } from 'vault/tests/helpers/components/shamir'; module('Integration | Component | shamir/form', function (hooks) { setupRenderingTest(hooks); @@ -21,32 +14,34 @@ module('Integration | Component | shamir/form', function (hooks) { test('it does calls callback only if key value present', async function (assert) { await render(hbs` - + `); - assert.dom(selectors.submitButton).hasText('Submit', 'Submit button has default text'); - await click(selectors.submitButton); + 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(selectors.input, 'this-is-the-key'); - assert.dom(selectors.input).hasValue('this-is-the-key', 'input value set'); - assert.dom(selectors.inputLabel).hasText('Shamir key portion', 'label has default text'); - await click(selectors.submitButton); + 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(selectors.input).hasValue('', 'key value reset after submit'); + assert.dom(SHAMIR_FORM.input).hasValue('', 'key value reset after submit'); // Template block usage: await render(hbs` - +
Hello
`); assert.dom('[data-test-block-content]').hasText('Hello', 'renders block content'); - assert.dom(selectors.submitButton).hasText('Do the thing', 'uses passed button text'); - assert.dom(selectors.inputLabel).hasText('Unseal key', 'uses passed inputLabel'); - assert.dom(selectors.otpInfo).doesNotExist('no OTP info shown'); + 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).exists('Shows progress even if 0 when alwaysShowProgress=true'); }); test('it shows OTP info if provided', async function (assert) { @@ -62,8 +57,23 @@ module('Integration | Component | shamir/form', function (hooks) {
`); - assert.dom(selectors.otpInfo).exists('shows OTP info'); - assert.dom(selectors.otpCode).hasText('this-is-otp', 'shows OTP code'); + assert.dom(SHAMIR_FORM.otpInfo).exists('shows OTP info'); + assert.dom(SHAMIR_FORM.otpCode).hasText('this-is-otp', 'shows OTP code'); 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'); + }); }); From fda9b8d6ee149923544383b98fe0653150ca0ad8 Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Mon, 17 Jul 2023 16:03:27 -0500 Subject: [PATCH 14/23] Test coverage for cluster adapter generateDrOperationToken --- ui/tests/unit/adapters/cluster-test.js | 60 ++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/ui/tests/unit/adapters/cluster-test.js b/ui/tests/unit/adapters/cluster-test.js index 21b6b2ad99c3..b8b8ace64ca5 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(); + }, + }); + + // Default + adapter.generateDrOperationToken({ key: 'foo' }, {}); + assert.strictEqual( + url, + '/v1/sys/replication/dr/secondary/generate-operation-token/attempt', + 'no options url correct' + ); + assert.strictEqual(method, 'PUT', 'no options method OK'); + assert.deepEqual({ data: { key: 'foo' }, unauthenticated: true }, options, 'no options payload OK'); + + // CheckStatus + 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 + adapter.generateDrOperationToken({}, { cancel: true }); + assert.strictEqual( + url, + '/v1/sys/replication/dr/secondary/generate-operation-token/update', + '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, 'PUT', 'pgp_key method OK'); + 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, 'PUT', 'data.attempt method OK'); + assert.deepEqual({ data: { attempt: true }, unauthenticated: true }, options, 'data.attempt options OK'); + }); }); From fd1639074f2569fa4cca01d77033bcbce1983a9b Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Tue, 18 Jul 2023 13:47:25 -0500 Subject: [PATCH 15/23] Choose-pgp-key-form tests --- .../addon/components/choose-pgp-key-form.hbs | 41 ++++++---- .../addon/components/choose-pgp-key-form.js | 17 ++++- .../components/choose-pgp-key-form-test.js | 74 +++++++++++++++---- 3 files changed, 103 insertions(+), 29 deletions(-) diff --git a/ui/lib/core/addon/components/choose-pgp-key-form.hbs b/ui/lib/core/addon/components/choose-pgp-key-form.hbs index 7cebce7b3e61..d5330fc16631 100644 --- a/ui/lib/core/addon/components/choose-pgp-key-form.hbs +++ b/ui/lib/core/addon/components/choose-pgp-key-form.hbs @@ -1,9 +1,18 @@ {{#if this.selectedPgp}} -
+
-

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

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

PGP Key @@ -11,39 +20,43 @@

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

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

+ {{this.formText}}

-
-
diff --git a/ui/lib/core/addon/components/choose-pgp-key-form.js b/ui/lib/core/addon/components/choose-pgp-key-form.js index 205cdad47d02..eeb7916b86ea 100644 --- a/ui/lib/core/addon/components/choose-pgp-key-form.js +++ b/ui/lib/core/addon/components/choose-pgp-key-form.js @@ -13,8 +13,10 @@ const pgpKeyFileDefault = () => ({ value: '' }); * ```js * * ``` - * @param {function} onCancel - This function will be triggered when the modal intends to be closed - * @param {function} onSubmit - When the PGP key is confirmed, it will call this method with the pgpKey value as the only param + * @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(); @@ -24,6 +26,17 @@ export default class ChoosePgpKeyForm extends Component { 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; } diff --git a/ui/tests/integration/components/choose-pgp-key-form-test.js b/ui/tests/integration/components/choose-pgp-key-form-test.js index 21c25826c930..6d539d3df709 100644 --- a/ui/tests/integration/components/choose-pgp-key-form-test.js +++ b/ui/tests/integration/components/choose-pgp-key-form-test.js @@ -1,26 +1,74 @@ +import sinon from 'sinon'; import { module, test } from 'qunit'; import { setupRenderingTest } from 'vault/tests/helpers'; -import { render } from '@ember/test-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); - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + hooks.beforeEach(function () { + this.set('onCancel', () => {}); + this.set('onSubmit', () => {}); + }); + + test('it renders correctly', async function (assert) { + await render( + hbs`` + ); - 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('my custom form text', 'uses custom form text'); + 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(this.element).hasText(''); + 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')); + }); - // Template block usage: - await render(hbs` - - template block text - - `); + test('it calls cancel on cancel', async function (assert) { + const cancelSpy = sinon.spy(); + this.set('onCancel', cancelSpy); + await render( + hbs`` + ); - assert.dom(this.element).hasText('template block text'); + 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); }); }); From 31cae971cdfbded125e63cb38b70e3fe00086184 Mon Sep 17 00:00:00 2001 From: Chelsea Shaw Date: Tue, 18 Jul 2023 18:20:18 -0500 Subject: [PATCH 16/23] dr-token-flow-test --- .../addon/components/shamir/dr-token-flow.hbs | 23 +- .../addon/components/shamir/dr-token-flow.js | 8 +- .../components/shamir/dr-token-flow-test.js | 217 ++++++++++++++++-- 3 files changed, 229 insertions(+), 19 deletions(-) diff --git a/ui/lib/core/addon/components/shamir/dr-token-flow.hbs b/ui/lib/core/addon/components/shamir/dr-token-flow.hbs index 7b875d82c29d..8ffe686db16b 100644 --- a/ui/lib/core/addon/components/shamir/dr-token-flow.hbs +++ b/ui/lib/core/addon/components/shamir/dr-token-flow.hbs @@ -1,6 +1,6 @@