UI: remodel shamir flow (#21871)

This commit is contained in:
Chelsea Shaw
2023-07-19 18:57:37 -05:00
committed by GitHub
parent 053349f771
commit ad3316fe2f
34 changed files with 1295 additions and 748 deletions

3
changelog/21871.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:improvement
ui: update unseal and DR operation token flow components
```

View File

@@ -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,

View File

@@ -15,6 +15,10 @@ export default Controller.extend({
});
},
reloadCluster() {
return this.model.reload();
},
isUnsealed(data) {
return data.sealed === false;
},

View File

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

View File

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

View File

@@ -25,7 +25,7 @@
<br />
How do you want to get started?
</h2>
<div class="vlt-radio is-block">
<div class="has-bottom-margin-s">
<RadioButton
id="join"
name="setup-pref"
@@ -33,10 +33,9 @@
@groupValue={{this.preference}}
@onChange={{fn (mut this.preference)}}
/>
<label for="join"></label>
Join an existing Raft cluster
<label for="join">Join an existing Raft cluster</label>
</div>
<div class="vlt-radio is-block">
<div>
<RadioButton
id="init"
name="setup-pref"
@@ -44,8 +43,7 @@
@groupValue={{this.preference}}
@onChange={{fn (mut this.preference)}}
/>
<label for="init" data-test-join-init></label>
Create a new Raft cluster
<label for="init" data-test-join-init>Create a new Raft cluster</label>
</div>
</div>
<div class="box is-marginless is-shadowless">

View File

@@ -31,21 +31,27 @@
is
{{if this.model.unsealed "unsealed" "sealed"}}
</p>
<p>
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.
</p>
{{#if this.model.unsealed}}
<p>Please wait while we redirect you.</p>
{{else}}
<p>
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.
</p>
<Shamir::Flow
@action="unseal"
@threshold={{this.model.sealThreshold}}
@progress={{this.model.sealProgress}}
@updateProgress={{action "reloadCluster"}}
@inputLabel="Unseal Key Portion"
@buttonText="Unseal"
@onLicenseError={{action "handleLicenseError"}}
@checkComplete={{action "isUnsealed"}}
@onShamirSuccess={{action "transitionToCluster"}}
class="has-top-margin-m"
/>
{{/if}}
</div>
<ShamirFlow
@action="unseal"
@onLicenseError={{action "handleLicenseError"}}
@onShamirSuccess={{action "transitionToCluster"}}
@buttonText="Unseal"
@thresholdPath="t"
@isComplete={{action "isUnsealed"}}
@threshold={{this.model.sealThreshold}}
@progress={{this.model.sealProgress}}
/>
</Page.content>
<Page.footer>
<div class="box is-borderless is-shadowless">

View File

@@ -0,0 +1,64 @@
{{#if this.selectedPgp}}
<form
id="confirm-pgp-key"
{{on "submit" this.handleSubmit}}
aria-label="begin token generation with PGP key"
data-test-confirm-pgp-key
>
<div>
<p class="has-bottom-margin-m" data-test-pgp-key-confirm>
{{or
@confirmText
(concat
'Below is the base-64 encoded PGP Key that will be used. Click the "' this.buttonText '" button to proceed.'
)
}}
</p>
<h4 class="field-title has-bottom-padding-m is-fullwidth">
{{concat "PGP Key " this.pgpKeyFile.filename}}
</h4>
<div class="message is-list has-copy-button" tabindex="-1">
<HoverCopyButton @copyValue={{this.pgpKey}} />
<code class="is-word-break" data-test-pgp-key-copy>{{this.pgpKey}}</code>
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button type="button" class="button" {{on "click" (fn (mut this.selectedPgp) "")}} data-test-confirm-pgp-back-button>
Back
</button>
</div>
<div class="control">
<button type="submit" disabled={{not this.pgpKey}} class="button is-primary" data-test-confirm-pgp-key-submit>
{{this.buttonText}}
</button>
</div>
</div>
</form>
{{else}}
<form
id="choose-pgp-key"
{{on "submit" this.usePgpKey}}
aria-label="provide PGP key"
data-test-choose-pgp-key-form="begin"
>
<div class="has-bottom-margin-m">
<p data-test-choose-pgp-key-description>
{{this.formText}}
</p>
<PgpFile @index="" @key={{this.pgpKeyFile}} @onChange={{this.setKey}} />
</div>
<div class="field is-grouped">
<div class="control">
<button type="button" class="button" {{on "click" @onCancel}} data-test-use-pgp-key-cancel>
Back
</button>
</div>
<div class="control">
<button type="submit" disabled={{not this.pgpKeyFile.value}} class="button is-primary" data-test-use-pgp-key-button>
Use PGP Key
</button>
</div>
</div>
</form>
{{/if}}

View File

@@ -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
* <ChoosePgpKeyForm @onCancel={{this.reset}} @onSubmit={{handleGenerateWithPgpKey}}>
* ```
* @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);
}
}

View File

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

View File

@@ -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
* <ShamirModalFlow @onClose={action 'onClose'}>This copy is the main paragraph when the token flow has not started</ShamirModalFlow>
* ```
* @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();
},
},
});

View File

@@ -0,0 +1,135 @@
<section class="modal-card-body">
{{#if this.encodedToken}}
<p class="has-bottom-margin-l" data-test-dr-token-flow-step="show-token">
Below is the process and the values necessary to generate your operation token. Read the instructions carefully!
</p>
<div class="has-bottom-margin-m">
<div class="has-bottom-margin-xl">
<h4 class="field-title">
Encoded operation token
</h4>
<p class="help has-text-grey has-bottom-margin-xs">
This is a one-time token that will be used to generate the operation token. Please save it.
</p>
<div class="message is-list has-copy-button" tabindex="-1">
<HoverCopyButton @copyValue={{this.encodedToken}} />
<code class="is-word-break" data-test-shamir-encoded-token>{{this.encodedToken}}</code>
</div>
</div>
{{#if this.otp}}
<div class="has-bottom-margin-xl">
<h4 class="field-title">
One time password (OTP)
</h4>
<p class="help has-text-grey has-bottom-margin-xs">
This OTP will be used to decode the generated operation token. Please save it.
</p>
<div class="message is-list has-copy-button" tabindex="-1">
<HoverCopyButton @copyValue={{this.otp}} />
<code class="is-word-break">{{this.otp}}</code>
</div>
</div>
{{/if}}
<div class="has-bottom-margin-xl">
<h4 class="field-title">
DR operation token command
</h4>
<p class="help has-text-grey has-bottom-margin-xs">
{{#if this.otp}}
This command contains both the encoded token and the OTP. It should be executed on the secondary cluster in order
to generate the operation token.
{{else}}
This command requires the OTP saved earlier. It should be executed on the secondary cluster in order to generate
the operation token.
{{/if}}
</p>
<div class="message is-list has-copy-button" tabindex="-1">
{{! template-lint-disable quotes }}
{{#let
(if
this.otp
(concat 'vault operator generate-root -dr-token -otp="' this.otp '" -decode="' this.encodedToken '"')
(concat 'vault operator generate-root -dr-token -otp="<enter your otp here>" -decode="' this.encodedToken '"')
)
as |cmd|
}}
<HoverCopyButton @copyValue={{cmd}} />
<code class="is-word-break">{{cmd}}</code>
{{/let}}
{{! template-lint-enable quotes }}
</div>
</div>
</div>
<div>
<button type="button" class="button is-primary" {{on "click" this.onCancelClose}}>
Clear &amp; Close
</button>
</div>
{{else if this.started}}
<Shamir::Form
@action={{@action}}
@progress={{this.progress}}
@threshold={{this.threshold}}
@errors={{this.errors}}
@inputLabel="Root key portion"
@buttonText="Generate token"
@onSubmit={{this.onSubmitKey}}
@otp={{this.otp}}
@alwaysShowProgress={{true}}
data-test-dr-token-flow-step="shamir"
>
<p>Generate an operation token by entering a portion of the
<strong>primary's root key</strong>. Once all portions are entered, the generated token may be used to manage your
secondary Disaster Recovery cluster.
</p>
</Shamir::Form>
{{else if this.generateWithPGP}}
<ChoosePgpKeyForm
@onCancel={{fn (mut this.generateWithPGP) false}}
@onSubmit={{this.usePgpKey}}
@formText={{this.pgpText.form}}
@confirmText={{this.pgpText.confirm}}
@buttonText="Generate operation token"
data-test-dr-token-flow-step="choose-pgp"
/>
{{else}}
{{! Generate token flow not started }}
<form
{{on "submit" this.startGenerate}}
id="shamir"
aria-label="shamir generate form"
data-test-dr-token-flow-step="begin"
>
<MessageError @errors={{this.errors}} />
<div class="has-bottom-margin-m" data-test-shamir-modal-body>
<p>
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.
</p>
</div>
<div class="field is-grouped is-flex-center">
<div class="control is-flex-row">
<button type="button" class="link" {{on "click" (fn (mut this.generateWithPGP) true)}} data-test-use-pgp-key-cta>
Provide PGP Key
</button>
</div>
<div class="control">
<span class="has-side-padding-s">
or
</span>
</div>
<div class="control">
<button type="submit" class="button is-primary" data-test-generate-token-cta>
Generate operation token
</button>
</div>
</div>
</form>
{{/if}}
</section>
<footer class="modal-card-foot modal-card-foot-outlined">
<button type="button" class="button is-secondary" {{on "click" this.onCancelClose}} data-test-shamir-modal-cancel-button>
{{if this.encodedToken "Close" "Cancel"}}
</button>
</footer>

View File

@@ -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
* <Shamir::DrTokenFlow @action="generate-dr-operation-token" @onCancel={{this.closeModal}} />
* ```
* @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
}
*/

View File

@@ -0,0 +1,9 @@
<Shamir::Form
@onSubmit={{this.onSubmitKey}}
@progress={{@progress}}
@threshold={{@threshold}}
@buttonText={{@buttonText}}
@inputLabel={{@inputLabel}}
@errors={{this.errors}}
...attributes
/>

View File

@@ -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
* <Shamir::Flow
* @action="unseal"
* @threshold={{5}}
* @progress={{3}}
* @onShamirSuccess={{transition-to "vault.cluster"}}
* />
* ```
*
* @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"
}
*/

View File

@@ -0,0 +1,48 @@
<form id="shamir" aria-label="shamir form" {{on "submit" (fn this.onSubmit this.key)}} ...attributes>
{{#if @errors}}
<div class="has-bottom-margin-m">
<MessageError @errors={{@errors}} />
</div>
{{/if}}
<div class="has-bottom-margin-m">
{{#if @otp}}
<Hds::Alert @type="inline" @color="highlight" class="has-bottom-margin-s" data-test-otp-info as |A|>
<A.Title>Info</A.Title>
<A.Description>
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.
</A.Description>
</Hds::Alert>
<div class="has-background-gray-100 box has-copy-button" tabindex="-1">
<HoverCopyButton @copyValue={{@otp}} />
<h4 class="title is-7 is-marginless">
One Time Password (otp)
</h4>
<code class="is-word-break" data-test-otp>{{@otp}}</code>
</div>
{{/if}}
{{#if (has-block)}}
{{yield}}
{{/if}}
</div>
<div class="field">
<label for="key" class="is-label" data-test-shamir-key-label>
{{this.inputLabel}}
</label>
<div class="control">
<Input class="input" @type="password" name="key" @value={{this.key}} autocomplete="off" data-test-shamir-key-input />
</div>
</div>
<div class="columns is-mobile">
<div class="column is-narrow">
<button type="submit" class="button is-primary" disabled={{this.loading}} data-test-shamir-submit>
{{this.buttonText}}
</button>
</div>
<div class="column is-flex-v-centered is-flex-end">
{{#if this.showProgress}}
<ShamirProgress @threshold={{@threshold}} @progress={{@progress}} />
{{/if}}
</div>
</div>
</form>

View File

@@ -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
* <Shamir::Form
* @onSubmit={{this.handleKeySubmit}}
* @threshold={{cluster.threshold}}
* @progress={{cluster.progress}}
* @fetchOnInit={{true}}
* @onShamirSuccess={{transition-to "vault.cluster"}}
* />
* ```
*
* @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();
}
}

View File

@@ -20,17 +20,16 @@
</div>
</div>
<ShamirModalFlow
@action="generate-dr-operation-token"
@buttonText="Generate token"
@fetchOnInit={{true}}
@generateAction={{true}}
<Modal
@title="Generate operation token"
@onClose={{action (mut this.isModalActive) false}}
@isActive={{this.isModalActive}}
@type="warning"
@showCloseButton={{true}}
>
<p>
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.
</p>
</ShamirModalFlow>
{{#if this.isModalActive}}
{{! Wrapped in if statement so the Shamir constructor fires on modal open }}
<Shamir::DrTokenFlow @action="generate-dr-operation-token" @onCancel={{action (mut this.isModalActive) false}} />
{{! Section & Footer is in child component since the form must do side effects on cancel }}
{{/if}}
</Modal>

View File

@@ -1,219 +0,0 @@
<Modal
@title="Generate operation token"
@onClose={{action "onClose"}}
@isActive={{this.isActive}}
@type="warning"
@showCloseButton={{true}}
>
<section class="modal-card-body">
{{#if this.encoded_token}}
<p class="has-bottom-margin-l">
Below is the process and the values necessary to generate your operation token. Read the instructions carefully!
</p>
<div class="has-bottom-margin-m">
<div class="has-bottom-margin-xl">
<h4 class="field-title">
Encoded operation token
</h4>
<p class="help has-text-grey has-bottom-margin-xs">
This is a one-time token that will be used to generate the operation token. Please save it.
</p>
<div class="message is-list has-copy-button" tabindex="-1">
<HoverCopyButton @copyValue={{this.encoded_token}} />
<code class="is-word-break" data-test-shamir-encoded-token>{{this.encoded_token}}</code>
</div>
</div>
{{#if this.otp}}
<div class="has-bottom-margin-xl">
<h4 class="field-title">
One time password (OTP)
</h4>
<p class="help has-text-grey has-bottom-margin-xs">
This OTP will be used to decode the generated operation token. Please save it.
</p>
<div class="message is-list has-copy-button" tabindex="-1">
<HoverCopyButton @copyValue={{this.otp}} />
<code class="is-word-break">{{this.otp}}</code>
</div>
</div>
{{/if}}
<div class="has-bottom-margin-xl">
<h4 class="field-title">
DR operation token command
</h4>
<p class="help has-text-grey has-bottom-margin-xs">
{{#if this.otp}}
This command contains both the encoded token and the OTP. It should be executed on the secondary cluster in
order to generate the operation token.
{{else}}
This command requires the OTP saved earlier. It should be executed on the secondary cluster in order to
generate the operation token.
{{/if}}
</p>
<div class="message is-list has-copy-button" tabindex="-1">
{{! template-lint-disable quotes }}
{{#let
(if
this.otp
(concat 'vault operator generate-root -dr-token -otp="' this.otp '" -decode="' this.encoded_token '"')
(concat
'vault operator generate-root -dr-token -otp="<enter your otp here>" -decode="' this.encoded_token '"'
)
)
as |cmd|
}}
<HoverCopyButton @copyValue={{cmd}} />
<code class="is-word-break">{{cmd}}</code>
{{/let}}
{{! template-lint-enable quotes }}
</div>
</div>
</div>
<div>
<button type="button" class="button is-primary" {{action "onCancelClose"}}>
Clear &amp; Close
</button>
</div>
{{else if (and this.generateAction (not this.started))}}
<form {{action "startGenerate" (hash pgp_key=this.pgp_key) on="submit"}} id="shamir" aria-label="shamir generate form">
<MessageError @errors={{this.errors}} />
{{#if (eq this.generateStep "chooseMethod")}}
<div class="has-bottom-margin-m" data-test-shamir-modal-body>
{{yield}}
</div>
<div class="field is-grouped is-flex-center">
<div class="control is-flex-row">
<button type="button" class="link" {{action (mut this.generateWithPGP) true}}>
Provide PGP Key
</button>
</div>
<div class="control">
<span class="has-side-padding-s">
or
</span>
</div>
<div class="control">
<button type="submit" class="button is-primary" data-test-generate-token-cta>
Generate operation token
</button>
</div>
</div>
{{/if}}
{{#if (eq this.generateStep "providePGPKey")}}
<div class="has-bottom-margin-m">
<p>
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.
</p>
<PgpFile @index="" @key={{this.pgpKeyFile}} @onChange={{action "setKey"}} />
</div>
<div class="field is-grouped">
<div class="control">
<button type="button" class="button" {{action "reset"}}>
Back
</button>
</div>
<div class="control">
<button type="button" disabled={{not this.pgp_key}} class="button is-primary" {{action "savePGPKey"}}>
Use PGP Key
</button>
</div>
</div>
{{/if}}
{{#if (eq this.generateStep "beginGenerationWithPGP")}}
<div>
<p class="has-bottom-margin-m">
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.
</p>
<h4 class="field-title has-bottom-padding-m is-fullwidth">
PGP Key
{{this.pgpKeyFile.filename}}
</h4>
<div class="message is-list has-copy-button" tabindex="-1">
<HoverCopyButton @copyValue={{this.pgp_key}} />
<code class="is-word-break">{{this.pgp_key}}</code>
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button type="button" class="button" {{action "reset"}}>
Back
</button>
</div>
<div class="control">
<button type="submit" disabled={{and (not this.pgp_key)}} class="button is-primary">
Generate operation token
</button>
</div>
</div>
{{/if}}
</form>
{{else}}
<form {{action "onSubmit" (hash key=this.key) on="submit"}} id="shamir" aria-label="shamir form">
<div class="has-bottom-margin-m">
{{#if this.errors}}
<div class="box is-shadowless is-marginless no-padding-top is-fullwidth">
<MessageError @errors={{this.errors}} />
</div>
{{/if}}
<div class="box is-shadowless is-marginless no-padding-top is-fullwidth no-padding-sides" data-test-form-text>
{{#if this.otp}}
<div class="has-bottom-margin-xxl">
<h4 class="field-title">
One-time password (OTP)
</h4>
<p class="help has-text-grey has-bottom-margin-xs">
This OTP will be used to decode the generated operation token.
<span class="has-text-weight-semibold">Save this</span>, as you will need it later to decode the operation
token.
</p>
<div class="message is-list has-copy-button" tabindex="-1">
<HoverCopyButton @copyValue={{this.otp}} />
<code class="is-word-break">{{this.otp}}</code>
</div>
</div>
{{/if}}
<p>
Generate an operation token by entering a portion of the primary's root key. Once all portions are entered, the
generated token may be used to manage your secondary Disaster Recovery cluster.
</p>
</div>
<div class="field">
<label for="key" class="is-label">
root key Portion
</label>
<div class="control">
<Input class="input" @type="password" name="key" @value={{this.key}} data-test-shamir-input={{true}} />
</div>
</div>
</div>
<div class="has-bottom-margin-m">
<div class="columns is-mobile">
<div class="column is-narrow">
<button type="submit" class="button is-primary" disabled={{this.loading}} data-test-shamir-submit={{true}}>
{{if this.generateAction "Generate Token" this.buttonText}}
</button>
</div>
<div class="column is-flex-v-centered is-flex-end">
{{#if (or this.started this.hasProgress)}}
<ShamirProgress @threshold={{this.threshold}} @progress={{this.progress}} />
{{/if}}
</div>
</div>
</div>
</form>
{{/if}}
</section>
<footer class="modal-card-foot modal-card-foot-outlined">
<button
type="button"
class="button is-secondary"
onclick={{action "onCancelClose"}}
data-test-shamir-modal-cancel-button
>
{{if this.encoded_token "Close" "Cancel"}}
</button>
</footer>
</Modal>

View File

@@ -0,0 +1 @@
export { default } from 'core/components/choose-pgp-key-form';

View File

@@ -1,6 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
export { default } from 'core/components/shamir-flow';

View File

@@ -1,6 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: MPL-2.0
*/
export { default } from 'core/components/shamir-modal-flow';

View File

@@ -0,0 +1 @@
export { default } from 'core/components/shamir/dr-token-flow';

View File

@@ -0,0 +1 @@
export { default } from 'core/components/shamir/flow';

View File

@@ -0,0 +1 @@
export { default } from 'core/components/shamir/form';

View File

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

View File

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

View File

@@ -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`<ChoosePgpKeyForm @onSubmit={{this.onSubmit}} @onCancel={{this.onCancel}} @formText="my custom form text" @buttonText="Do it" />`
);
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`<ChoosePgpKeyForm @onSubmit={{this.onSubmit}} @onCancel={{this.onCancel}} @buttonText="Submit" />`
);
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`<ChoosePgpKeyForm @onSubmit={{this.onSubmit}} @onCancel={{this.onCancel}} @buttonText="Submit" />`
);
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);
});
});

View File

@@ -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`<ShamirFlow @formText="like whoa" />`);
assert.dom('form [data-test-form-text]').hasText('like whoa', 'renders formText inline');
await render(hbs`
<ShamirFlow @formText="like whoa">
<p>whoa again</p>
</ShamirFlow>
`);
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`
<ShamirFlow @progress={{1}} @threshold={{5}} />
`);
assert.dom('.shamir-progress').hasText('1/5 keys provided', 'displays textual progress');
this.set('errors', ['first error', 'this is fine']);
await render(hbs`
<ShamirFlow @errors={{this.errors}} />
`);
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`
<ShamirFlow @key={{this.key}} @action="foo" @thresholdPath="required" />
`);
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`
<ShamirFlow @key={{this.key}} @action="foo" @isComplete={{action this.checkComplete}} @onShamirSuccess={{action this.onSuccess}} />
`);
await click('[data-test-shamir-submit]');
});
test('it fetches progress on init when fetchOnInit is true', async function (assert) {
await render(hbs`
<ShamirFlow @action="foo" @fetchOnInit={{true}} />
`);
assert
.dom('.shamir-progress')
.hasText(`${response.progress}/${response.required} keys provided`, 'displays the correct progress');
});
});

View File

@@ -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`
<div id="modal-wormhole"></div>
<ShamirModalFlow
@action="generate-dr-operation-token"
@buttonText="Generate token"
@fetchOnInit=true
@generateAction=true
@buttonText="My CTA"
@onClose={{this.onClose}}
@isActive={{this.isActive}}
>
<p>Inner content goes here</p>
</ShamirModalFlow>
`);
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`
<div id="modal-wormhole"></div>
<ShamirModalFlow
@started=true
@action="generate-dr-operation-token"
@buttonText="Generate token"
@fetchOnInit=true
@generateAction=true
@buttonText="Crazy CTA"
@onClose={{this.onClose}}
@isActive={{this.isActive}}
>
<p>Inner content goes here</p>
</ShamirModalFlow>
`);
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`
<div id="modal-wormhole"></div>
<ShamirModalFlow
@encoded_token="my-encoded-token"
@action="generate-dr-operation-token"
@buttonText="Generate token"
@fetchOnInit=true
@generateAction=true
@buttonText="Crazy CTA"
@onClose={{this.onClose}}
@isActive={{this.isActive}}
>
<p>Inner content goes here</p>
</ShamirModalFlow>
`);
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
});
});

View File

@@ -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`<Shamir::DrTokenFlow @action="generate-dr-operation-token" />`);
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`<Shamir::DrTokenFlow @action="generate-dr-operation-token" />`);
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`<Shamir::DrTokenFlow @action="generate-dr-operation-token" />`);
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`<Shamir::DrTokenFlow @action="generate-dr-operation-token" @onCancel={{this.onCancel}} />`
);
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`<Shamir::DrTokenFlow @action="generate-dr-operation-token" @onCancel={{this.onCancel}} />`
);
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`<Shamir::DrTokenFlow @action="generate-dr-operation-token" @onCancel={{this.onCancel}} />`
);
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');
});
});

View File

@@ -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`
<Shamir::Flow
@action="foo"
@threshold={{3}}
@progress={{this.progress}}
@updateProgress={{this.updateProgress}}
@checkComplete={{this.checkComplete}}
@onShamirSuccess={{this.onSuccess}}
/>`);
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`
<Shamir::Flow
@action="response-with-errors"
@threshold={{3}}
@progress={{2}}
@updateProgress={{this.updateProgress}}
@checkComplete={{this.checkComplete}}
/>`);
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`
<Shamir::Flow
@action="response-with-license"
@threshold={{3}}
@progress={{2}}
@onLicenseError={{this.onLicenseError}}
/>`);
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');
}
});
});

View File

@@ -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`
<Shamir::Form @onSubmit={{this.submitSpy}} @progress={{0}} @threshold={{3}} />
`);
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`
<Shamir::Form @onSubmit={{this.submitSpy}} @progress={{0}} @threshold={{3}} @alwaysShowProgress={{true}} @buttonText="Do the thing" @inputLabel="Unseal key">
<div data-test-block-content>Hello</div>
</Shamir::Form>
`);
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`
<Shamir::Form
@onSubmit={{this.submitSpy}}
@progress={{2}}
@threshold={{4}}
@otp="this-is-otp"
@inputLabel="Please input key"
>
<div data-test-block-content>Hello</div>
</Shamir::Form>
`);
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`
<Shamir::Form
@onSubmit={{this.submitSpy}}
@errors={{this.errors}}
/>
`);
assert.dom(SHAMIR_FORM.error).exists({ count: 2 }, 'renders errors');
this.set('errors', []);
await settled();
assert.dom(SHAMIR_FORM.error).doesNotExist('errors cleared');
});
});

View File

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