Merge branch 'main' into patch-1

This commit is contained in:
divyaac 2024-08-20 11:33:33 -07:00 committed by GitHub
commit ecb0df365b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 1612 additions and 713 deletions

View file

@ -146,7 +146,7 @@ runs:
BUNDLE_PATH: out/${{ steps.metadata.outputs.artifact-basename }}.zip
shell: bash
run: make ci-bundle
- uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
- uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: ${{ steps.metadata.outputs.artifact-basename }}.zip
path: out/${{ steps.metadata.outputs.artifact-basename }}.zip
@ -178,13 +178,13 @@ runs:
echo "deb-files=$(basename out/*.deb)"
} | tee -a "$GITHUB_OUTPUT"
- if: inputs.create-packages == 'true'
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: ${{ steps.package-files.outputs.rpm-files }}
path: out/${{ steps.package-files.outputs.rpm-files }}
if-no-files-found: error
- if: inputs.create-packages == 'true'
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: ${{ steps.package-files.outputs.deb-files }}
path: out/${{ steps.package-files.outputs.deb-files }}

View file

@ -40,7 +40,7 @@ runs:
else
echo "go-version=${{ inputs.go-version }}" | tee -a "$GITHUB_OUTPUT"
fi
- uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1
- uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
with:
go-version: ${{ steps.go-version.outputs.go-version }}
cache: false # We use our own caching strategy

View file

@ -177,7 +177,7 @@ jobs:
key: ui-${{ steps.ui-hash.outputs.ui-hash }}
- if: steps.cache-ui-assets.outputs.cache-hit != 'true'
name: Set up node and yarn
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
with:
node-version-file: ui/package.json
cache: yarn

View file

@ -154,17 +154,17 @@ jobs:
with:
github-token: ${{ secrets.ELEVATED_GITHUB_TOKEN }}
# Setup node.js without caching to allow running npm install -g yarn (next step)
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
- uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
with:
node-version-file: './ui/package.json'
- run: npm install -g yarn
# Setup node.js with caching using the yarn.lock file
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
- uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
with:
node-version-file: './ui/package.json'
cache: yarn
cache-dependency-path: ui/yarn.lock
- uses: browser-actions/setup-chrome@db1b524c26f20a8d1a10f7fc385c92387e2d0477 # v1.7.1
- uses: browser-actions/setup-chrome@facf10a55b9caf92e0cc749b4f82bf8220989148 # v1.7.2
with:
# Temporarily pin our Chrome version while we sort out a broken test on latest
chrome-version: 1314712
@ -335,7 +335,7 @@ jobs:
# to secrets.
- if: ${{ needs.setup.outputs.is-fork == 'false' }}
name: Download failure summaries
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
pattern: failure-summary-*.md
path: failure-summaries

View file

@ -44,7 +44,7 @@ jobs:
github-token: ${{ secrets.ELEVATED_GITHUB_TOKEN }}
- name: Download Docker Image
id: download
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
name: ${{ inputs.artifact-name }}
path: ./enos/support/downloads

View file

@ -16,10 +16,10 @@ on:
jobs:
scan:
runs-on: ${{ github.repository == 'hashicorp/vault' && 'ubuntu-latest' || fromJSON('["self-hosted","ondemand","os=linux","type=c6a.4xlarge"]') }}
# The first check ensures this doesn't run on community-contributed PRs, who
# won't have the permissions to run this job.
# The first check ensures this doesn't run on community-contributed PRs, who won't have the
# permissions to run this job.
if: |
(startsWith(github.repository, 'hashicorp/vault') || (github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name)) &&
! github.event.pull_request.head.repo.fork &&
github.actor != 'dependabot[bot]' &&
github.actor != 'hc-github-team-secure-vault-core'
steps:

View file

@ -82,7 +82,7 @@ jobs:
- name: Set Up Git
run: git config --global url."https://${{ secrets.elevated_github_token }}:@github.com".insteadOf "https://github.com"
- name: Set Up Node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
with:
node-version-file: './ui/package.json'
- name: Set Up Terraform
@ -108,7 +108,7 @@ jobs:
sudo apt install -y libnss3-dev libgdk-pixbuf2.0-dev libgtk-3-dev libxss-dev libasound2
- name: Install Chrome
if: steps.chrome-check.outputs.chrome-version == 'not-installed'
uses: browser-actions/setup-chrome@db1b524c26f20a8d1a10f7fc385c92387e2d0477 # v1.7.1
uses: browser-actions/setup-chrome@facf10a55b9caf92e0cc749b4f82bf8220989148 # v1.7.2
- name: Installed Chrome Version
run: |
echo "Installed Chrome Version = [$(chrome --version 2> /dev/null || google-chrome --version 2> /dev/null || google-chrome-stable --version 2> /dev/null)]"

View file

@ -617,7 +617,7 @@ jobs:
data-race-output: ${{ steps.status.outputs.data-race-output }}
data-race-result: ${{ steps.status.outputs.data-race-result }}
steps:
- uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
- uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
pattern: ${{ needs.test-go.outputs.data-race-log-download-pattern }}
path: data-race-logs
@ -666,7 +666,7 @@ jobs:
restore-keys: |
${{ inputs.test-timing-cache-key }}-
- if: ${{ ! cancelled() && needs.test-go.result == 'success' && inputs.test-timing-cache-enabled }}
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
path: ${{ needs.test-matrix.outputs.go-test-dir }}
pattern: ${{ needs.test-go.outputs.go-test-results-download-pattern }}

View file

@ -132,7 +132,7 @@ jobs:
chmod 600 "./enos/support/private_key.pem"
echo "debug_data_artifact_name=enos-debug-data_$(echo "${{ matrix.scenario }}" | sed -e 's/ /_/g' | sed -e 's/:/=/g')" >> "$GITHUB_OUTPUT"
- if: contains(inputs.sample-name, 'build')
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
name: ${{ inputs.build-artifact-name }}
path: ./enos/support/downloads

3
changelog/27457.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:improvement
sdk/helper: Allow setting environment variables when using NewTestDockerCluster
```

2
go.mod
View file

@ -146,7 +146,7 @@ require (
github.com/hashicorp/vault-plugin-secrets-ad v0.18.0
github.com/hashicorp/vault-plugin-secrets-alicloud v0.17.0
github.com/hashicorp/vault-plugin-secrets-azure v0.19.2
github.com/hashicorp/vault-plugin-secrets-gcp v0.19.0
github.com/hashicorp/vault-plugin-secrets-gcp v0.19.1-0.20240725202923-b240a5ad808a
github.com/hashicorp/vault-plugin-secrets-gcpkms v0.17.0
github.com/hashicorp/vault-plugin-secrets-kubernetes v0.8.0
github.com/hashicorp/vault-plugin-secrets-kv v0.19.0

4
go.sum
View file

@ -1586,8 +1586,8 @@ github.com/hashicorp/vault-plugin-secrets-alicloud v0.17.0 h1:b8mTj3e/3JgkEpBFq3
github.com/hashicorp/vault-plugin-secrets-alicloud v0.17.0/go.mod h1:9eT3ysHGq0jIarcJFcb9rZoaVbQoAsG3Iug8KM9H83w=
github.com/hashicorp/vault-plugin-secrets-azure v0.19.2 h1:/9eARO5fffhRh7/oLVgdKlylTl9mQuk5oXUVksvUjH8=
github.com/hashicorp/vault-plugin-secrets-azure v0.19.2/go.mod h1:R8CJArW9qdsp7ycyKndTaHo7Zj4vXjFMgMXchr/ct1s=
github.com/hashicorp/vault-plugin-secrets-gcp v0.19.0 h1:5FRAVA3JYpn7zDMebQ3V622NxMKtY6tJg3YDuvTzS3A=
github.com/hashicorp/vault-plugin-secrets-gcp v0.19.0/go.mod h1:/xdangTVszef3HQdZ0Ct2MmokRuYSpjx/SyX7aWxTbg=
github.com/hashicorp/vault-plugin-secrets-gcp v0.19.1-0.20240725202923-b240a5ad808a h1:6v0mIf3QIbViuOBmTlG0Msnudp03WamA87sCR/AlCjw=
github.com/hashicorp/vault-plugin-secrets-gcp v0.19.1-0.20240725202923-b240a5ad808a/go.mod h1:/xdangTVszef3HQdZ0Ct2MmokRuYSpjx/SyX7aWxTbg=
github.com/hashicorp/vault-plugin-secrets-gcpkms v0.17.0 h1:GeARWON8OFWZFsulNOQqvDRG2H/38l9ye35VFk5MO1g=
github.com/hashicorp/vault-plugin-secrets-gcpkms v0.17.0/go.mod h1:IQl89mmTl+GISbF16Rxr2d8YCfUfEdLeZJkV2rjuZCQ=
github.com/hashicorp/vault-plugin-secrets-kubernetes v0.8.0 h1:PhLMvNO+VbTYzX04tZDkgQIrBxjDBYTbVFQsa7qbE84=

View file

@ -805,20 +805,23 @@ func (n *DockerClusterNode) Start(ctx context.Context, opts *DockerClusterOption
}
}
envs := []string{
// For now we're using disable_mlock, because this is for testing
// anyway, and because it prevents us using external plugins.
"SKIP_SETCAP=true",
"VAULT_LOG_FORMAT=json",
"VAULT_LICENSE=" + opts.VaultLicense,
}
envs = append(envs, opts.Envs...)
r, err := dockhelper.NewServiceRunner(dockhelper.RunOptions{
ImageRepo: n.ImageRepo,
ImageTag: n.ImageTag,
// We don't need to run update-ca-certificates in the container, because
// we're providing the CA in the raft join call, and otherwise Vault
// servers don't talk to one another on the API port.
Cmd: append([]string{"server"}, opts.Args...),
Env: []string{
// For now we're using disable_mlock, because this is for testing
// anyway, and because it prevents us using external plugins.
"SKIP_SETCAP=true",
"VAULT_LOG_FORMAT=json",
"VAULT_LICENSE=" + opts.VaultLicense,
},
Cmd: append([]string{"server"}, opts.Args...),
Env: envs,
Ports: ports,
ContainerName: n.Name(),
NetworkName: opts.NetworkName,
@ -1089,6 +1092,7 @@ type DockerClusterOptions struct {
CA *testcluster.CA
VaultBinary string
Args []string
Envs []string
StartProbe func(*api.Client) error
Storage testcluster.ClusterStorage
DisableTLS bool

View file

@ -0,0 +1,38 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package docker
import (
"testing"
)
func TestSettingEnvsToContainer(t *testing.T) {
expectedEnv := "TEST_ENV=value1"
expectedEnv2 := "TEST_ENV2=value2"
opts := &DockerClusterOptions{
ImageRepo: "hashicorp/vault",
ImageTag: "latest",
Envs: []string{expectedEnv, expectedEnv2},
}
cluster := NewTestDockerCluster(t, opts)
defer cluster.Cleanup()
envs := cluster.GetActiveClusterNode().Container.Config.Env
if !findEnv(envs, expectedEnv) {
t.Errorf("Missing ENV variable: %s", expectedEnv)
}
if !findEnv(envs, expectedEnv2) {
t.Errorf("Missing ENV variable: %s", expectedEnv2)
}
}
func findEnv(envs []string, env string) bool {
for _, e := range envs {
if e == env {
return true
}
}
return false
}

View file

@ -87,13 +87,6 @@ export default ApplicationAdapter.extend({
}
},
findRecord(store, type, path, snapshot) {
if (snapshot.attr('type') === 'ssh') {
return this.ajax(`/v1/${encodePath(path)}/config/ca`, 'GET');
}
return { data: {} };
},
queryRecord(store, type, query) {
if (query.type === 'aws') {
return this.ajax(`/v1/${encodePath(query.backend)}/config/lease`, 'GET').then((resp) => {

View file

@ -8,13 +8,39 @@ import { encodePath } from 'vault/utils/path-encoding-helpers';
export default class SshCaConfig extends ApplicationAdapter {
namespace = 'v1';
// For now this is only being used on the vault.cluster.secrets.backend.configuration route. This is a read-only route.
// Eventually, this will be used to create the ca config for the SSH secret backend, replacing the requests located on the secret-engine adapter.
queryRecord(store, type, query) {
const { backend } = query;
return this.ajax(`${this.buildURL()}/${encodePath(backend)}/config/ca`, 'GET').then((resp) => {
resp.id = backend;
resp.backend = backend;
return resp;
});
}
createOrUpdate(store, type, snapshot) {
const serializer = store.serializerFor(type.modelName);
const data = serializer.serialize(snapshot);
const backend = snapshot.record.backend;
return this.ajax(`${this.buildURL()}/${backend}/config/ca`, 'POST', { data }).then((resp) => {
// ember data requires an id on the response
return {
...resp,
id: backend,
};
});
}
createRecord() {
return this.createOrUpdate(...arguments);
}
updateRecord() {
return this.createOrUpdate(...arguments);
}
deleteRecord(store, type, snapshot) {
const backend = snapshot.record.backend;
return this.ajax(`${this.buildURL()}/${backend}/config/ca`, 'DELETE');
}
}

View file

@ -6,13 +6,14 @@
{{#if @configModels.length}}
{{#each @configModels as |configModel|}}
{{#each configModel.attrs as |attr|}}
{{#if attr.options.sensitive}}
{{! public key while not sensitive when editing/creating, should be hidden by default on viewing }}
{{#if (or attr.options.sensitive (eq attr.name "publicKey"))}}
<InfoTableRow
alwaysRender={{not (is-empty-value (get configModel attr.name))}}
@label={{or attr.options.label (to-label attr.name)}}
@value={{get configModel (or attr.options.fieldValue attr.name)}}
>
{{#if attr.options.sensitive}}
{{#if (or attr.options.sensitive (eq attr.name "publicKey"))}}
<MaskedInput
@value={{get configModel attr.name}}
@name={{attr.name}}

View file

@ -3,88 +3,84 @@
SPDX-License-Identifier: BUSL-1.1
~}}
{{#if @configured}}
<div class="box is-fullwidth is-sideless is-marginless" data-test-edit-config-section>
<div class="field">
<label for="publicKey" class="is-label">
Public key
</label>
<form {{on "submit" (perform this.save)}} aria-label="save ssh creds" data-test-configure-form>
<div class="box is-fullwidth is-shadowless is-marginless">
<NamespaceReminder @mode="save" @noun="configuration" />
<MessageError @errorMessage={{this.errorMessage}} />
{{#unless @model.isNew}}
<p class="has-text-grey-dark">
NOTE: You must delete your existing certificate and key before saving new values.
</p>
{{/unless}}
</div>
{{#if @model.isNew}}
<div class="box is-fullwidth is-sideless">
{{#each @model.formFields as |attr|}}
<FormField @attr={{attr}} @model={{@model}} @modelValidations={{this.modelValidations}} />
{{/each}}
</div>
<div class="box is-fullwidth is-bottomless">
<div class="control">
<MaskedInput
@name="publickey"
@id="publicKey"
@value={{@model.publicKey}}
@displayOnly={{true}}
@allowCopy={{true}}
data-test-input="public-key"
<Hds::Button
@text="Save"
@icon={{if this.save.isRunning "loading"}}
type="submit"
disabled={{this.save.isRunning}}
data-test-configure-save-button
/>
<Hds::Button
@text="Cancel"
@color="secondary"
class="has-left-margin-s"
disabled={{this.save.isRunning}}
{{on "click" this.onCancel}}
data-test-cancel-button
/>
</div>
{{#if this.invalidFormAlert}}
<AlertInline
data-test-invalid-form-alert
class="has-top-padding-s"
@type="danger"
@message={{this.invalidFormAlert}}
/>
{{/if}}
</div>
</div>
<div class="field is-grouped-split box is-fullwidth is-bottomless">
<Hds::ButtonSet>
<Hds::Copy::Button
@text="Copy"
@textToCopy={{@model.publicKey}}
@onError={{(fn (set-flash-message "Clipboard copy failed. The Clipboard API requires a secure context." "danger"))}}
class="primary"
/>
<ConfirmAction
@buttonText="Delete"
@buttonColor="secondary"
@confirmMessage="This will remove the CA certificate information."
@onConfirmAction={{this.delete}}
data-test-delete-public-key
/>
</Hds::ButtonSet>
</div>
{{else}}
<form {{on "submit" this.saveConfig}} data-test-configure-form>
<div class="box is-fullwidth is-sideless is-marginless">
<NamespaceReminder @mode="save" @noun="configuration" />
<div class="field">
<label for="privateKey" class="is-label">
Private key
</label>
<div class="control">
<MaskedInput @name="privateKey" id="privateKey" @value={{@model.privateKey}} @onChange={{mut @model.privateKey}} />
</div>
</div>
{{else}}
{{! Model is not new and keys have already been created. Require user deletes the keys before creating new ones }}
<div class="box is-fullwidth is-sideless is-marginless" data-test-edit-config-section>
<div class="field">
<label for="publicKey" class="is-label">
Public key
</label>
<div class="control">
<Textarea name="publicKey" id="publicKey" class="input" @value={{@model.publicKey}} data-test-input="publicKey" />
<MaskedInput
@name="publickey"
@id="publicKey"
@value={{@model.publicKey}}
@displayOnly={{true}}
@allowCopy={{true}}
data-test-input="public-key"
/>
</div>
</div>
<div class="b-checkbox">
<Input
@type="checkbox"
id="generateSigningKey"
class="styled"
@checked={{@model.generateSigningKey}}
{{on "change" (fn (mut @model.generateSigningKey) (not @model.generateSigningKey))}}
data-test-input="generate-signing-key-checkbox"
/>
<label for="generateSigningKey" class="is-label">
Generate signing key
<InfoTooltip>
Specifies if Vault should generate the signing key pair internally
</InfoTooltip>
</label>
</div>
</div>
<div class="field box is-fullwidth is-bottomless is-marginless">
<div class="control">
<Hds::Button
@text="Save"
@icon={{if @loading "loading"}}
type="submit"
disabled={{@loading}}
data-test-configure-save-button
<div class="field is-grouped-split box is-fullwidth is-bottomless">
<Hds::ButtonSet>
<Hds::Copy::Button
@text="Copy"
@textToCopy={{@model.publicKey}}
@onError={{fn (set-flash-message "Clipboard copy failed. The Clipboard API requires a secure context." "danger")}}
class="primary"
/>
</div>
<ConfirmAction
@buttonText="Delete"
@buttonColor="secondary"
@confirmMessage="Confirming will remove the CA certificate information."
@onConfirmAction={{this.deleteCaConfig}}
data-test-delete-public-key
/>
</Hds::ButtonSet>
</div>
</form>
{{/if}}
{{/if}}
</form>

View file

@ -1,38 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
/**
* @module ConfigureSshSComponent
*
* @example
* ```js
* <SecretEngine::ConfigureSsh
* @model={{this.model}}
* @configured={{this.configured}}
* @saveConfig={{action "saveConfig"}}
* @loading={{this.loading}}
* />
* ```
*
* @param {string} model - ssh secret engine model
* @param {Function} saveConfig - parent action which updates the configuration
* @param {boolean} loading - property in parent that updates depending on status of parent's action
*
*/
export default class ConfigureSshComponent extends Component {
@action
delete() {
this.args.saveConfig({ delete: true });
}
@action
saveConfig(event) {
event.preventDefault();
this.args.saveConfig({ delete: false });
}
}

View file

@ -0,0 +1,118 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { ValidationMap } from 'vault/vault/app-types';
import errorMessage from 'vault/utils/error-message';
import type CaConfigModel from 'vault/models/ssh/ca-config';
import type Router from '@ember/routing/router';
import type Store from '@ember-data/store';
import type FlashMessageService from 'vault/services/flash-messages';
/**
* @module ConfigureSshComponent is used to configure the SSH secret engine.
*
* @example
* ```js
* <SecretEngine::ConfigureSsh
* @model={{this.model.ssh-ca-config}}
* @id={{this.model.id}}
* />
* ```
*
* @param {string} model - SSH ca-config model
* @param {string} id - name of the SSH secret engine, ex: 'ssh-123'
*/
interface Args {
model: CaConfigModel;
id: string;
}
export default class ConfigureSshComponent extends Component<Args> {
@service declare readonly router: Router;
@service declare readonly store: Store;
@service declare readonly flashMessages: FlashMessageService;
@tracked errorMessage: string | null = null;
@tracked invalidFormAlert: string | null = null;
@tracked modelValidations: ValidationMap | null = null;
@task
@waitFor
*save(event: Event) {
event.preventDefault();
this.resetErrors();
const { id, model } = this.args;
const isValid = this.validate(model);
if (!isValid) return;
// Check if any of the model's attributes have changed.
// If no changes to the model, transition and notify user.
// Otherwise, save the model.
const attributesChanged = Object.keys(model.changedAttributes()).length > 0;
if (!attributesChanged) {
this.flashMessages.info('No changes detected.');
this.transition();
}
try {
yield model.save();
this.transition();
this.flashMessages.success(`Successfully saved ${id}'s root configuration.`);
} catch (error) {
this.errorMessage = errorMessage(error);
this.invalidFormAlert = 'There was an error submitting this form.';
}
}
validate(model: CaConfigModel) {
const { isValid, state, invalidFormMessage } = model.validate();
this.modelValidations = isValid ? null : state;
this.invalidFormAlert = isValid ? '' : invalidFormMessage;
return isValid;
}
resetErrors() {
this.flashMessages.clearMessages();
this.errorMessage = null;
this.invalidFormAlert = null;
}
transition(isDelete = false) {
// deleting a key is the only case in which we want to stay on the create/edit page.
if (isDelete) {
this.router.transitionTo('vault.cluster.secrets.backend.configuration.edit', this.args.id);
} else {
this.router.transitionTo('vault.cluster.secrets.backend.configuration', this.args.id);
}
}
@action
onCancel() {
// clear errors because they're canceling out of the workflow.
this.resetErrors();
this.transition();
}
@action
async deleteCaConfig() {
const { model } = this.args;
try {
await model.destroyRecord();
this.transition(true);
this.flashMessages.success('CA information deleted successfully.');
} catch (error) {
model.rollbackAttributes();
this.flashMessages.danger(errorMessage(error));
}
}
}

View file

@ -29,31 +29,6 @@ export default Controller.extend(CONFIG_ATTRS, {
this.setProperties(CONFIG_ATTRS);
},
actions: {
saveConfig(options = { delete: false }) {
const isDelete = options.delete;
if (this.model.type === 'ssh') {
this.set('loading', true);
this.model
.saveCA({ isDelete })
.then(() => {
this.send('refreshRoute');
this.set('configured', !isDelete);
if (isDelete) {
this.flashMessages.success('SSH Certificate Authority Configuration deleted!');
} else {
this.flashMessages.success('SSH Certificate Authority Configuration saved!');
}
})
.catch((error) => {
const errorMessage = error.errors ? error.errors.join('. ') : error;
this.flashMessages.danger(errorMessage);
})
.finally(() => {
this.set('loading', false);
});
}
},
save(method, data) {
this.set('loading', true);
const hasData = Object.keys(data).some((key) => {

View file

@ -69,11 +69,6 @@ export default class KvSecretMetadataModel extends Model {
return keyIsFolder(this.path);
}
// cannot use isDeleted due to ember property conflict
get isSecretDeleted() {
return isDeleted(this.deletionTime);
}
// turns version object into an array for version dropdown menu
get sortedVersions() {
const array = [];
@ -93,6 +88,7 @@ export default class KvSecretMetadataModel extends Model {
return {
state,
isDeactivated: state !== 'created',
deletionTime: data.deletion_time,
};
}

View file

@ -68,14 +68,6 @@ export default class SecretEngineModel extends Model {
})
version;
// SSH specific attributes
@attr('string') privateKey;
@attr('string') publicKey;
@attr('boolean', {
defaultValue: true,
})
generateSigningKey;
// AWS specific attributes
@attr('string') lease;
@attr('string') leaseMax;
@ -257,24 +249,6 @@ export default class SecretEngineModel extends Model {
}
/* ACTIONS */
saveCA(options) {
if (this.type !== 'ssh') {
return;
}
if (options.isDelete) {
this.privateKey = null;
this.publicKey = null;
this.generateSigningKey = false;
}
return this.save({
adapterOptions: {
options: options,
apiPath: 'config/ca',
attrsToSend: ['privateKey', 'publicKey', 'generateSigningKey'],
},
});
}
saveZeroAddressConfig() {
return this.save({
adapterOptions: {

View file

@ -5,16 +5,50 @@
import Model, { attr } from '@ember-data/model';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
import { withModelValidations } from 'vault/decorators/model-validations';
const validations = {
generateSigningKey: [
{
validator(model) {
const { publicKey, privateKey, generateSigningKey } = model;
// if generateSigningKey is false, both public and private keys are required
if (!generateSigningKey && (!publicKey || !privateKey)) {
return false;
}
return true;
},
message: 'Provide a Public and Private key or set "Generate Signing Key" to true.',
},
],
publicKey: [
{
validator(model) {
const { publicKey, privateKey } = model;
// regardless of generateSigningKey, if one key is set they both need to be set.
return publicKey || privateKey ? publicKey && privateKey : true;
},
message: 'You must provide a Public and Private keys or leave both unset.',
},
],
};
// there are more options available on the API, but the UI does not support them yet.
@withModelValidations(validations)
export default class SshCaConfig extends Model {
@attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord
@attr('string', { sensitive: true }) privateKey; // obfuscated, never returned by API
@attr('string', { sensitive: true }) publicKey;
@attr('string') publicKey;
@attr('boolean', { defaultValue: true })
generateSigningKey;
// there are more options available on the API, but the UI does not support them yet.
// do not return private key for configuration.index view
get attrs() {
const keys = ['publicKey', 'generateSigningKey'];
return expandAttributeMeta(this, keys);
}
// return private key for edit/create view
get formFields() {
const keys = ['privateKey', 'publicKey', 'generateSigningKey'];
return expandAttributeMeta(this, keys);
}
}

View file

@ -1,70 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import AdapterError from '@ember-data/adapter/error';
import { set } from '@ember/object';
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { CONFIGURABLE_SECRET_ENGINES } from 'vault/helpers/mountable-secret-engines';
export default Route.extend({
store: service(),
model() {
const { backend } = this.paramsFor('vault.cluster.secrets.backend');
return this.store.query('secret-engine', { path: backend }).then((modelList) => {
const model = modelList && modelList[0];
if (!model || !CONFIGURABLE_SECRET_ENGINES.includes(model.type)) {
const error = new AdapterError();
set(error, 'httpStatus', 404);
throw error;
}
return this.store.findRecord('secret-engine', backend).then(
() => {
return model;
},
() => {
return model;
}
);
});
},
afterModel(model) {
const type = model.type;
if (type === 'aws') {
return this.store
.queryRecord('secret-engine', {
backend: model.id,
type,
})
.then(
() => model,
() => model
);
}
return model;
},
setupController(controller, model) {
if (model.publicKey) {
controller.set('configured', true);
}
return this._super(...arguments);
},
resetController(controller, isExiting) {
if (isExiting) {
controller.reset();
}
},
actions: {
refreshRoute() {
this.refresh();
},
},
});

View file

@ -0,0 +1,109 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import AdapterError from '@ember-data/adapter/error';
import { set } from '@ember/object';
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { CONFIGURABLE_SECRET_ENGINES } from 'vault/helpers/mountable-secret-engines';
import errorMessage from 'vault/utils/error-message';
import { action } from '@ember/object';
import type Store from '@ember-data/store';
import type SecretEngineModel from 'vault/models/secret-engine';
// This route file is reused for all configurable secret engines.
// It generates config models based on the engine type.
// Saving and updating of those models are done within the engine specific components.
const CONFIG_ADAPTERS_PATHS: Record<string, string[]> = {
// aws: ['aws/lease-config', 'aws/root-config'], TODO will be uncommented when AWS refactor occurs
ssh: ['ssh/ca-config'],
};
export default class SecretsBackendConfigurationEdit extends Route {
@service declare readonly store: Store;
async model() {
const { backend } = this.paramsFor('vault.cluster.secrets.backend');
const secretEngineRecord = this.modelFor('vault.cluster.secrets.backend') as { type: SecretEngineModel };
const type = secretEngineRecord.type as string;
// if the engine type is not configurable, return a 404.
if (!secretEngineRecord || !CONFIGURABLE_SECRET_ENGINES.includes(type)) {
const error = new AdapterError();
set(error, 'httpStatus', 404);
throw error;
}
// TODO this conditional will be removed when we handle AWS
if (type !== 'aws') {
// generate the model based on the engine type.
// and pre-set with the type and backend (e.g. type: ssh, id: ssh-123)
const model: Record<string, unknown> = { type, id: backend };
for (const adapterPath of CONFIG_ADAPTERS_PATHS[type] as string[]) {
// convert the adapterPath with a name that can be passed to the components
// ex: adapterPath = ssh/ca-config, convert to: ssh-ca-config so that you can pass to component @model={{this.model.ssh-ca-config}}
const standardizedKey = adapterPath.replace(/\//g, '-');
try {
model[standardizedKey] = await this.store.queryRecord(adapterPath, {
backend,
type,
});
} catch (e: AdapterError) {
// For most models if the adapter returns a 404, we want to create a new record.
// The ssh secret engine however returns a 400 if the CA is not configured.
// For ssh's 400 error, we want to create the CA config model.
if (
e.httpStatus === 404 ||
(type === 'ssh' && e.httpStatus === 400 && errorMessage(e) === `keys haven't been configured yet`)
) {
model[standardizedKey] = await this.store.createRecord(adapterPath, {
backend,
type,
});
} else {
throw e;
}
}
}
return model;
} else {
// TODO for now AWS configs rely on the secret-engine model and adapter. This will be refactored.
return await this.store.findRecord('secret-engine', backend);
}
}
// TODO everything below line will be removed once we handle AWS. This is the old code wrapped in AWS conditionals when appropriate.
afterModel(model: Record<string, unknown>) {
const type = model.type;
if (type === 'aws') {
return this.store
.queryRecord('secret-engine', {
backend: model.id,
type,
})
.then(
() => model,
() => model
);
}
return model;
}
resetController(controller, isExiting) {
if (controller.model.type === 'aws') {
if (isExiting) {
controller.reset();
}
}
}
@action
willTransition() {
// catch the transition and refresh model so the route shows the most recent model data.
this.refresh();
}
}

View file

@ -41,12 +41,5 @@
@saveAWSLease={{action "save" "saveAWSLease"}}
/>
{{else if (eq this.model.type "ssh")}}
<SecretEngine::ConfigureSsh
@model={{this.model}}
@configured={{this.configured}}
@saveConfig={{action "saveConfig"}}
@loading={{this.loading}}
/>
{{/if}}
{{outlet}}
<SecretEngine::ConfigureSsh @model={{this.model.ssh-ca-config}} @id={{this.model.id}} />
{{/if}}

View file

@ -16,6 +16,7 @@ import { obfuscateData } from 'core/utils/advanced-secret';
* <JsonEditor @title="Policy" @value={{hash foo="bar"}} @viewportMargin={{100}} />
*
* @param {string} [title] - Name above codemirror view
* @param {boolean} [showToolbar=true] - If false, toolbar and title are hidden
* @param {string} value - a specific string the comes from codemirror. It's the value inside the codemirror display
* @param {Function} [valueUpdated] - action to preform when you edit the codemirror value.
* @param {Function} [onFocusOut] - action to preform when you focus out of codemirror.

View file

@ -10,10 +10,14 @@
data-test-overview-card-container={{@cardTitle}}
...attributes
>
<div class="flex row-wrap space-between has-bottom-margin-s" data-test-overview-card={{@cardTitle}}>
<Hds::Text::Display @weight="bold" @size="300" data-test-overview-card-title={{@cardTitle}}>
{{@cardTitle}}
</Hds::Text::Display>
<div class="flex row-wrap space-between" data-test-overview-card={{@cardTitle}}>
{{#if (has-block "customTitle")}}
{{yield to="customTitle"}}
{{else}}
<Hds::Text::Display @weight="semibold" @size="300" data-test-overview-card-title={{@cardTitle}}>
{{@cardTitle}}
</Hds::Text::Display>
{{/if}}
{{#if (has-block "action")}}
{{yield to="action"}}

View file

@ -43,7 +43,7 @@ function dateFromString(str) {
return null;
}
export function dateFormat([value, style], { withTimeZone = false }) {
export function dateFormat([value, style = 'MMM d yyyy, h:mm:ss aa'], { withTimeZone = false }) {
// see format breaking in upgrade to date-fns 2.x https://github.com/date-fns/date-fns/blob/master/CHANGELOG.md#changed-5
let date;
switch (checkType(value)) {

View file

@ -0,0 +1,6 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export { default } from 'core/helpers/date-from-now';

View file

@ -0,0 +1,82 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<div data-test-overview-card-container="Paths" ...attributes>
<Hds::Text::Display @weight="semibold" @size="300" @tag="h2">
Paths
</Hds::Text::Display>
{{#if @isCondensed}}
<Hds::Text::Body @tag="p" @color="faint">
The paths to use when referring to this secret in API or CLI.
</Hds::Text::Body>
{{/if}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless {{if @isCondensed 'is-shadowless'}} ">
{{#each this.paths as |path|}}
<InfoTableRow
@label={{path.label}}
@labelWidth={{if @isCondensed "is-one-quarter" "is-one-third"}}
@helperText={{if @isCondensed "" path.text}}
@truncateValue={{true}}
>
<Hds::Copy::Button
@text="Copy"
@isIconOnly={{true}}
@textToCopy={{path.snippet}}
@onError={{fn (set-flash-message "Clipboard copy failed. The Clipboard API requires a secure context." "danger")}}
data-test-copy-button={{path.snippet}}
class="transparent"
/>
<code class="is-flex-1 text-overflow-ellipsis has-left-margin-s">
{{path.snippet}}
</code>
</InfoTableRow>
{{/each}}
</div>
{{#unless @isCondensed}}
<Hds::Text::Display @weight="semibold" @size="300" @tag="h3" class="has-top-margin-xl">
Commands
</Hds::Text::Display>
<div class="box is-fullwidth is-sideless">
<h3 class="is-label">
CLI
<Hds::Badge @text="kv get" @color="neutral" />
</h3>
<p class="helper-text has-text-grey-light has-bottom-padding-s">
This command retrieves the value from KV secrets engine at the given key name. See our
<Hds::Link::Inline @isHrefExternal={{true}} @href={{doc-link "/vault/docs/commands/kv"}}>
documentation</Hds::Link::Inline>
for other CLI commands.
</p>
<Hds::CodeBlock
data-test-commands="cli"
@language="bash"
@hasLineNumbers={{false}}
@hasCopyButton={{true}}
@value={{this.commands.cli}}
/>
<h3 class="has-top-margin-l is-label">
API read secret version
</h3>
<p class="helper-text has-text-grey-light has-bottom-padding-s">
This command obtains data and metadata for the latest version of this secret. In this example, Vault is located at
https://127.0.0.1:8200. For other API commands,
<DocLink @path="/vault/api-docs/secret/kv/kv-v2">
learn more.
</DocLink>
</p>
<Hds::CodeBlock
data-test-commands="api"
@language="bash"
@hasLineNumbers={{false}}
@hasCopyButton={{true}}
@value={{this.commands.api}}
/>
</div>
{{/unless}}
</div>

View file

@ -9,23 +9,21 @@ import { kvMetadataPath, kvDataPath } from 'vault/utils/kv-path';
import { encodePath } from 'vault/utils/path-encoding-helpers';
/**
* @module KvSecretPaths is used to display copyable secret paths for KV v2 for CLI and API use.
* This view is permission agnostic because args come from the views mount path and url params.
* @module KvPathsCard is used to display copyable secret paths for KV v2 for CLI and API use.
* This component is permission agnostic because args come from the views mount path and url params.
*
* <Page::Secret::Paths
* <KvPathsCard
* @path={{this.model.path}}
* @backend={{this.model.backend}}
* @breadcrumbs={{this.breadcrumbs}}
* @canReadMetadata={{this.model.secret.canReadMetadata}}
* @isCondensed={{false}}
* />
*
* @param {string} path - kv secret path for building the CLI and API paths
* @param {string} backend - the secret engine mount path, comes from the secretMountPath service defined in the route
* @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component
* @param {boolean} [canReadMetadata=true] - if true, displays tab for Version History
* @param {boolean} isCondensed - if true a smaller version displays with no commands section or extra explanatory text
*/
export default class KvSecretPaths extends Component {
export default class KvPathsCard extends Component {
@service namespace;
get paths() {
@ -46,11 +44,15 @@ export default class KvSecretPaths extends Component {
snippet: namespace ? `-namespace=${namespace} ${cli}` : cli,
text: 'Use this path when referring to this secret in the CLI.',
},
{
label: 'API path for metadata',
snippet: namespace ? `/v1/${encodePath(namespace)}/${metadata}` : `/v1/${metadata}`,
text: `Use this path when referring to this secret's metadata in the API and permanent secret deletion.`,
},
...(this.args.isCondensed
? []
: [
{
label: 'API path for metadata',
snippet: namespace ? `/v1/${encodePath(namespace)}/${metadata}` : `/v1/${metadata}`,
text: `Use this path when referring to this secret's metadata in the API and permanent secret deletion.`,
},
]),
];
}

View file

@ -3,9 +3,9 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<OverviewCard @cardTitle="Subkeys">
<OverviewCard @cardTitle="Subkeys" class="has-top-margin-l">
<:customSubtext>
<Hds::Text::Body @color="faint" data-test-overview-card-subtitle="Subkeys">
<Hds::Text::Body @tag="p" @color="faint" data-test-overview-card-subtitle="Subkeys">
{{#if this.showJson}}
These are the subkeys within this secret. All underlying values of leaf keys are not retrieved and are replaced with
<code>null</code>

View file

@ -0,0 +1,105 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}>
<:tabLinks>
<li>
<LinkTo @route="secret.index" @models={{array @backend @path}} data-test-secrets-tab="Overview">Overview</LinkTo>
</li>
<li>
<LinkTo @route="secret.details" @models={{array @backend @path}} data-test-secrets-tab="Secret">Secret</LinkTo>
</li>
<li>
<LinkTo
@route="secret.metadata.index"
@models={{array @backend @path}}
data-test-secrets-tab="Metadata"
>Metadata</LinkTo>
</li>
<li>
<LinkTo @route="secret.paths" @models={{array @backend @path}} data-test-secrets-tab="Paths">Paths</LinkTo>
</li>
{{#if @canReadMetadata}}
<li>
<LinkTo
@route="secret.metadata.versions"
@models={{array @backend @path}}
data-test-secrets-tab="Version History"
>Version History</LinkTo>
</li>
{{/if}}
</:tabLinks>
</KvPageHeader>
{{#if (or @metadata @subkeys)}}
<div class="flex row-wrap gap-24 has-top-margin-l">
<OverviewCard @cardTitle="Current version" @subText={{this.versionSubtext}} class="is-flex-1">
<:customTitle>
<Hds::Text::Display @weight="semibold" @size="300">
Current version
{{#unless this.isActive}}
<Hds::Badge
@text={{capitalize @secretState}}
@type={{if (eq @secretState "destroyed") "outlined" "inverted"}}
@color={{if (eq @secretState "destroyed") "critical"}}
@icon="x"
/>
{{/unless}}
</Hds::Text::Display>
</:customTitle>
<:action>
{{#if @canUpdateSecret}}
<Hds::Link::Standalone
@text="Create new"
@route="secret.details.edit"
@models={{array @backend @path}}
@icon="plus"
@iconPosition="trailing"
/>
{{/if}}
</:action>
<:content>
<Hds::Text::Display @tag="p" @size="400" @weight="medium" class="has-top-margin-m">
{{or @metadata.currentVersion @subkeys.metadata.version}}
</Hds::Text::Display>
</:content>
</OverviewCard>
{{#if this.isActive}}
{{#let (or @metadata.createdTime @subkeys.metadata.created_time) as |timestamp|}}
<OverviewCard
@cardTitle="Secret age"
@subText="Time since last update at {{date-format timestamp}}."
class="is-flex-1"
>
<:action>
{{#if @canReadMetadata}}
<Hds::Link::Standalone
@text="View metadata"
@route="secret.metadata"
@models={{array @backend @path}}
@icon="arrow-right"
@iconPosition="trailing"
/>
{{/if}}
</:action>
<:content>
<Hds::Text::Display @tag="p" @size="400" @weight="medium" class="has-top-margin-m">
{{date-from-now timestamp}}
</Hds::Text::Display>
</:content>
</OverviewCard>
{{/let}}
{{/if}}
</div>
{{/if}}
<Hds::Card::Container @level="mid" @hasBorder={{true}} class="has-padding-l has-top-margin-l">
<KvPathsCard @backend={{@backend}} @path={{@path}} @isCondensed={{true}} />
</Hds::Card::Container>
{{#if @subkeys.subkeys}}
<KvSubkeys @subkeys={{@subkeys.subkeys}} />
{{/if}}

View file

@ -0,0 +1,51 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { dateFormat } from 'core/helpers/date-format';
/**
* @module KvSecretOverview
* <Page::Secret::Overview
* @backend={{this.model.backend}}
* @breadcrumbs={{this.breadcrumbs}}
* @canReadMetadata={{true}}
* @canUpdateSecret={{true}}
* @metadata={{this.model.metadata}}
* @path={{this.model.path}}
* @secretState="created"
* @subkeys={{this.model.subkeys}}
* />
*
* @param {string} backend - kv secret mount to make network request
* @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component
* @param {boolean} canReadMetadata - permissions to read metadata
* @param {boolean} canUpdateSecret - permissions to create a new version of a secret
* @param {model} metadata - Ember data model: 'kv/metadata'
* @param {string} path - path to request secret data for selected version
* @param {string} secretState - if a secret has been "destroyed", "deleted" or "created" (still active)
* @param {object} subkeys - API response from subkeys endpoint, object with "subkeys" and "metadata" keys
*/
export default class KvSecretOverview extends Component {
get isActive() {
const state = this.args.secretState;
return state !== 'destroyed' && state !== 'deleted';
}
get versionSubtext() {
const state = this.args.secretState;
if (state === 'destroyed') {
return 'The current version of this secret has been permanently deleted and cannot be restored.';
}
if (state === 'deleted') {
const time =
this.args.metadata?.currentSecret.deletionTime || this.args.subkeys?.metadata.deletion_time;
const date = dateFormat([time], {});
return `The current version of this secret was deleted ${date}.`;
}
return 'The current version of this secret.';
}
}

View file

@ -36,66 +36,4 @@
</:tabLinks>
</KvPageHeader>
<h2 class="title is-5 has-top-margin-xl">
Paths
</h2>
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#each this.paths as |path|}}
<InfoTableRow @label={{path.label}} @labelWidth="is-one-third" @helperText={{path.text}} @truncateValue={{true}}>
<Hds::Copy::Button
@text="Copy"
@isIconOnly={{true}}
@textToCopy={{path.snippet}}
@onError={{(fn (set-flash-message "Clipboard copy failed. The Clipboard API requires a secure context." "danger"))}}
data-test-copy-button={{path.snippet}}
class="transparent"
/>
<code class="is-flex-1 text-overflow-ellipsis has-left-margin-s">
{{path.snippet}}
</code>
</InfoTableRow>
{{/each}}
</div>
<h2 class="title is-5 has-top-margin-xl">
Commands
</h2>
<div class="box is-fullwidth is-sideless">
<h3 class="is-label">
CLI
<Hds::Badge @text="kv get" @color="neutral" />
</h3>
<p class="helper-text has-text-grey-light has-bottom-padding-s">
This command retrieves the value from KV secrets engine at the given key name. For other CLI commands,
<DocLink @path="/vault/docs/commands/kv">
learn more.
</DocLink>
</p>
<Hds::CodeBlock
data-test-commands="cli"
@language="bash"
@hasLineNumbers={{false}}
@hasCopyButton={{true}}
@value={{this.commands.cli}}
/>
<h3 class="has-top-margin-l is-label">
API read secret version
</h3>
<p class="helper-text has-text-grey-light has-bottom-padding-s">
This command obtains data and metadata for the latest version of this secret. In this example, Vault is located at
https://127.0.0.1:8200. For other API commands,
<DocLink @path="/vault/api-docs/secret/kv/kv-v2">
learn more.
</DocLink>
</p>
<Hds::CodeBlock
data-test-commands="api"
@language="bash"
@hasLineNumbers={{false}}
@hasCopyButton={{true}}
@value={{this.commands.api}}
/>
</div>
<KvPathsCard @backend={{@backend}} @path={{@path}} class="has-top-margin-xl" />

View file

@ -18,7 +18,7 @@
/>
</:action>
<:content>
<Hds::Text::Display @tag="h2" @size="500">
<Hds::Text::Display @tag="p" @size="400" @weight="medium" class="has-top-margin-m">
{{format-number (if (eq @issuers 404) 0 @issuers.length)}}
</Hds::Text::Display>
</:content>
@ -38,7 +38,7 @@
/>
</:action>
<:content>
<Hds::Text::Display @tag="h2" @size="500">
<Hds::Text::Display @tag="p" @size="400" @weight="medium" class="has-top-margin-m">
{{format-number (if (eq @roles 404) 0 @roles.length)}}
</Hds::Text::Display>
</:content>

View file

@ -14,6 +14,8 @@ import { click, currentURL, currentRouteName, visit } from '@ember/test-helpers'
import { runCmd, tokenWithPolicyCmd } from 'vault/tests/helpers/commands';
import { clearRecords } from 'vault/tests/helpers/pki/pki-helpers';
import { PKI_OVERVIEW } from 'vault/tests/helpers/pki/pki-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
const { overviewCard } = GENERAL;
module('Acceptance | pki overview', function (hooks) {
setupApplicationTest(hooks);
@ -59,9 +61,9 @@ module('Acceptance | pki overview', function (hooks) {
test('navigates to view issuers when link is clicked on issuer card', async function (assert) {
await authPage.login(this.pkiAdminToken);
await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
assert.dom(PKI_OVERVIEW.issuersCardTitle).hasText('Issuers');
assert.dom(PKI_OVERVIEW.issuersCardOverviewNum).hasText('1');
await click(PKI_OVERVIEW.issuersCardLink);
assert.dom(overviewCard.title('Issuers')).hasText('Issuers');
assert.dom(`${overviewCard.container('Issuers')} p`).hasText('1');
await click(overviewCard.actionLink('Issuers'));
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/issuers`);
await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
});
@ -69,9 +71,9 @@ module('Acceptance | pki overview', function (hooks) {
test('navigates to view roles when link is clicked on roles card', async function (assert) {
await authPage.login(this.pkiAdminToken);
await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
assert.dom(PKI_OVERVIEW.rolesCardTitle).hasText('Roles');
assert.dom(PKI_OVERVIEW.rolesCardOverviewNum).hasText('0');
await click(PKI_OVERVIEW.rolesCardLink);
assert.dom(overviewCard.title('Roles')).hasText('Roles');
assert.dom(`${overviewCard.container('Roles')} p`).hasText('0');
await click(overviewCard.actionLink('Roles'));
assert.strictEqual(currentURL(), `/vault/secrets/${this.mountPath}/pki/roles`);
await runCmd([
`write ${this.mountPath}/roles/some-role \
@ -81,14 +83,14 @@ module('Acceptance | pki overview', function (hooks) {
max_ttl="720h"`,
]);
await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
assert.dom(PKI_OVERVIEW.rolesCardOverviewNum).hasText('1');
assert.dom(`${overviewCard.container('Roles')} p`).hasText('1');
});
test('hides roles card if user does not have permissions', async function (assert) {
await authPage.login(this.pkiIssuersList);
await visit(`/vault/secrets/${this.mountPath}/pki/overview`);
assert.dom(PKI_OVERVIEW.rolesCardTitle).doesNotExist('Roles card does not exist');
assert.dom(PKI_OVERVIEW.issuersCardTitle).exists('Issuers card exists');
assert.dom(overviewCard.title('Roles')).doesNotExist('Roles card does not exist');
assert.dom(overviewCard.title('Issuers')).hasText('Issuers');
});
test('navigates to generate certificate page for Issue Certificates card', async function (assert) {

View file

@ -3,11 +3,10 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { click, fillIn, currentURL, waitFor, visit } from '@ember/test-helpers';
import { click, fillIn, currentURL, visit, waitFor } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { v4 as uuidv4 } from 'uuid';
import { spy } from 'sinon';
import authPage from 'vault/tests/pages/auth';
import enablePage from 'vault/tests/pages/settings/mount-secret-backend';
@ -23,10 +22,7 @@ module('Acceptance | ssh | configuration', function (hooks) {
setupMirage(hooks);
hooks.beforeEach(function () {
const flash = this.owner.lookup('service:flash-messages');
this.flashDangerSpy = spy(flash, 'danger');
this.store = this.owner.lookup('service:store');
this.uid = uuidv4();
return authPage.login();
});
@ -70,18 +66,20 @@ module('Acceptance | ssh | configuration', function (hooks) {
await click(SES.ssh.save);
assert.strictEqual(
currentURL(),
`/vault/secrets/${sshPath}/configuration/edit`,
'stays on configuration form page.'
`/vault/secrets/${sshPath}/configuration`,
'navigates to the details page.'
);
// There is a delay in the backend for the public key to be generated, wait for it to complete by checking that the public key is displayed
await waitFor(GENERAL.inputByAttr('public-key'));
assert.dom(GENERAL.inputByAttr('public-key')).hasText('***********', 'public key is masked');
await waitFor(GENERAL.infoRowLabel('Public key'));
assert.dom(GENERAL.infoRowLabel('Public key')).exists('public key shown on the details screen');
await click(SES.configure);
assert
.dom(SES.ssh.editConfigSection)
.exists('renders the edit configuration section of the form and not the create part');
// delete Public key
await click(SES.ssh.deletePublicKey);
assert.dom(GENERAL.confirmMessage).hasText('This will remove the CA certificate information.');
await click(SES.ssh.delete);
assert.dom(GENERAL.confirmMessage).hasText('Confirming will remove the CA certificate information.');
await click(GENERAL.confirmButton);
assert.strictEqual(
currentURL(),
@ -90,9 +88,7 @@ module('Acceptance | ssh | configuration', function (hooks) {
);
assert.dom(GENERAL.maskedInput('privateKey')).hasNoText('Private key is empty and reset');
assert.dom(GENERAL.inputByAttr('publicKey')).hasNoText('Public key is empty and reset');
assert
.dom(GENERAL.inputByAttr('generate-signing-key-checkbox'))
.isNotChecked('Generate signing key is unchecked');
assert.dom(GENERAL.inputByAttr('generateSigningKey')).isChecked('Generate signing key is checked');
await click(SES.viewBackend);
await click(SES.configTab);
assert
@ -107,15 +103,15 @@ module('Acceptance | ssh | configuration', function (hooks) {
await enablePage.enable('ssh', path);
await click(SES.configTab);
await click(SES.configure);
assert
.dom(GENERAL.inputByAttr('generate-signing-key-checkbox'))
.isChecked('generate_signing_key defaults to true');
await click(GENERAL.inputByAttr('generate-signing-key-checkbox'));
assert.dom(GENERAL.inputByAttr('generateSigningKey')).isChecked('generate_signing_key defaults to true');
await click(GENERAL.inputByAttr('generateSigningKey'));
await click(SES.ssh.save);
assert.true(this.flashDangerSpy.calledWith('missing public_key'), 'Danger flash message is displayed');
assert
.dom(GENERAL.inlineError)
.hasText('Provide a Public and Private key or set "Generate Signing Key" to true.');
// visit the details page and confirm the public key is not shown
await visit(`/vault/secrets/${path}/configuration`);
assert.dom(GENERAL.infoRowLabel('Public key')).doesNotExist('Public Key label does not exist');
assert.dom(GENERAL.infoRowLabel('Public key')).doesNotExist('Public key label does not exist');
assert.dom(GENERAL.emptyStateTitle).hasText('SSH not configured', 'SSH not configured');
// cleanup
await runCmd(`delete sys/mounts/${path}`);

View file

@ -3,7 +3,16 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { click, fillIn, currentURL, find, settled, waitUntil, currentRouteName } from '@ember/test-helpers';
import {
click,
fillIn,
currentURL,
find,
settled,
waitUntil,
currentRouteName,
waitFor,
} from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { v4 as uuidv4 } from 'uuid';
@ -102,8 +111,9 @@ module('Acceptance | ssh | roles', function (hooks) {
await click(SES.configure);
// default has generate CA checked so we just submit the form
await click(SES.ssh.save);
await click(SES.viewBackend);
// There is a delay in the backend for the public key to be generated, wait for it to complete by checking that the public key is displayed
await waitFor(GENERAL.infoRowLabel('Public key'));
await click(GENERAL.tab(sshPath));
for (const role of ROLES) {
// create a role
await click(SES.createSecret);

View file

@ -64,7 +64,7 @@ module('Acceptance | sync | overview', function (hooks) {
await click(ts.navLink('Secrets Sync'));
await click(ts.destinations.list.create);
await click(ts.createCancel);
await click(ts.overviewCard.actionLink('Create new'));
await click(ts.overviewCard.actionText('Create new'));
await click(ts.createCancel);
await waitFor(ts.overview.table.actionToggle(0));
await click(ts.overview.table.actionToggle(0));

View file

@ -74,11 +74,12 @@ export const GENERAL = {
removeSelected: '[data-test-selected-list-button="delete"]',
},
overviewCard: {
container: (title: string) => `[data-test-overview-card-container="${title}"]`,
title: (title: string) => `[data-test-overview-card-title="${title}"]`,
description: (title: string) => `[data-test-overview-card-subtitle="${title}"]`,
content: (title: string) => `[data-test-overview-card-content="${title}"]`,
action: (title: string) => `[data-test-overview-card-container="${title}"] [data-test-action-text]`,
actionLink: (label: string) => `[data-test-action-text="${label}"]`,
actionText: (text: string) => `[data-test-action-text="${text}"]`,
actionLink: (label: string) => `[data-test-overview-card="${label}"] a`,
},
pagination: {
next: '.hds-pagination-nav__arrow--direction-next',

View file

@ -7,6 +7,9 @@
import { click, fillIn, visit, settled } from '@ember/test-helpers';
import { FORM } from './kv-selectors';
import { encodePath } from 'vault/utils/path-encoding-helpers';
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
import { assert } from '@ember/debug';
import { kvMetadataPath } from 'vault/utils/kv-path';
// CUSTOM ACTIONS RELEVANT TO KV-V2
@ -66,3 +69,30 @@ export function clearRecords(store) {
store.unloadAll('kv/metatata');
store.unloadAll('capabilities');
}
// TEST SETUP HELPERS
// sets basic path, backend, and metadata
export const baseSetup = (context) => {
assert(
`'baseSetup()' requires mirage: import { setupMirage } from 'ember-cli-mirage/test-support'`,
context.server
);
context.store = context.owner.lookup('service:store');
context.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
context.backend = 'kv-engine';
context.path = 'my-secret';
context.metadata = metadataModel(context, { withCustom: false });
};
export const metadataModel = (context, { withCustom = false }) => {
const metadata = withCustom
? context.server.create('kv-metadatum', 'withCustomMetadata')
: context.server.create('kv-metadatum');
metadata.id = kvMetadataPath(context.backend, context.path);
context.store.pushPayload('kv/metadata', {
modelName: 'kv/metadata',
...metadata,
});
return context.store.peekRecord('kv/metadata', metadata.id);
};

View file

@ -4,23 +4,12 @@
*/
export const PKI_OVERVIEW = {
issuersCardTitle: '[data-test-overview-card-title="Issuers"]',
issuersCardSubtitle: '[data-test-overview-card-subtitle="Issuers"]',
issuersCardLink: '[data-test-overview-card-container="Issuers"] a',
issuersCardOverviewNum: '[data-test-overview-card-container="Issuers"] h2',
rolesCardTitle: '[data-test-overview-card-title="Roles"]',
rolesCardSubtitle: '[data-test-overview-card-subtitle="Roles"]',
rolesCardLink: '[data-test-overview-card-container="Roles"] a',
rolesCardOverviewNum: '[data-test-overview-card-container="Roles"] h2',
issueCertificate: '[data-test-overview-card-title="Issue certificate"]',
issueCertificateInput: '[data-test-issue-certificate-input]',
issueCertificatePowerSearch: '[data-test-issue-certificate-input] span',
issueCertificateButton: '[data-test-issue-certificate-button]',
viewCertificate: '[data-test-overview-card-title="View certificate"]',
viewCertificateInput: '[data-test-view-certificate-input]',
viewCertificatePowerSearch: '[data-test-view-certificate-input] span',
viewCertificateButton: '[data-test-view-certificate-button]',
viewIssuerInput: '[data-test-issue-issuer-input]',
viewIssuerPowerSearch: '[data-test-issue-issuer-input] span',
viewIssuerButton: '[data-test-view-issuer-button]',
firstPowerSelectOption: '[data-option-index="0"]',

View file

@ -48,11 +48,13 @@ const createAwsLeaseConfig = (store, backend) => {
};
const createSshCaConfig = (store, backend) => {
// consider this model a placeholder for the actual ssh/ca-config model that has been generated with data. isNew is false.
store.pushPayload('ssh/ca-config', {
id: backend,
modelName: 'ssh/ca-config',
data: {
backend,
public_key: '123456',
generate_signing_key: true,
},
});

View file

@ -32,8 +32,9 @@ export const SECRET_ENGINE_SELECTORS = {
ssh: {
configureForm: '[data-test-configure-form]',
editConfigSection: '[data-test-edit-config-section]',
deletePublicKey: '[data-test-delete-public-key]',
save: '[data-test-configure-save-button]',
cancel: '[data-test-cancel-button]',
delete: '[data-test-delete-public-key]',
createRole: '[data-test-role-ssh-create]',
deleteRole: '[data-test-ssh-role-delete]',
},

View file

@ -0,0 +1,137 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupEngine } from 'ember-engines/test-support';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { PAGE } from 'vault/tests/helpers/kv/kv-selectors';
/* eslint-disable no-useless-escape */
module('Integration | Component | kv-v2 | KvPathsCard', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kv');
hooks.beforeEach(async function () {
this.backend = 'kv-engine';
this.path = 'my-secret';
this.isCondensed = false;
this.assertClipboard = (assert, element, expected) => {
assert.dom(element).hasAttribute('data-test-copy-button', expected);
};
this.renderComponent = async () => {
return render(
hbs`<KvPathsCard @backend={{this.backend}} @path={{this.path}} @isCondensed={{this.isCondensed}} />`,
{
owner: this.engine,
}
);
};
});
test('it renders condensed version', async function (assert) {
this.isCondensed = true;
await this.renderComponent();
assert.dom('[data-test-component="info-table-row"] .helper-text').doesNotExist('subtext does not render');
assert.dom('[data-test-label-div]').hasClass('is-one-quarter');
assert.dom(PAGE.infoRowValue('API path for metadata')).doesNotExist();
assert.dom(PAGE.paths.codeSnippet('cli')).doesNotExist();
assert.dom(PAGE.paths.codeSnippet('api')).doesNotExist();
const paths = [
{ label: 'API path', expected: `/v1/${this.backend}/data/${this.path}` },
{ label: 'CLI path', expected: `-mount="${this.backend}" "${this.path}"` },
];
for (const path of paths) {
assert.dom(PAGE.infoRowValue(path.label)).hasText(path.expected);
this.assertClipboard(assert, PAGE.paths.copyButton(path.label), path.expected);
}
});
test('it renders uncondensed version', async function (assert) {
await this.renderComponent();
assert.dom('[data-test-component="info-table-row"] .helper-text').exists('subtext renders');
assert.dom('[data-test-label-div]').hasClass('is-one-third');
assert.dom(PAGE.infoRowValue('API path for metadata')).exists();
assert.dom(PAGE.paths.codeSnippet('cli')).exists();
assert.dom(PAGE.paths.codeSnippet('api')).exists();
});
test('it renders copyable paths', async function (assert) {
const paths = [
{ label: 'API path', expected: `/v1/${this.backend}/data/${this.path}` },
{ label: 'CLI path', expected: `-mount="${this.backend}" "${this.path}"` },
{ label: 'API path for metadata', expected: `/v1/${this.backend}/metadata/${this.path}` },
];
await this.renderComponent();
for (const path of paths) {
assert.dom(PAGE.infoRowValue(path.label)).hasText(path.expected);
this.assertClipboard(assert, PAGE.paths.copyButton(path.label), path.expected);
}
});
test('it renders copyable encoded mount and secret paths', async function (assert) {
this.path = `my spacey!"secret`;
this.backend = `my fancy!"backend`;
const backend = encodeURIComponent(this.backend);
const path = encodeURIComponent(this.path);
const paths = [
{
label: 'API path',
expected: `/v1/${backend}/data/${path}`,
},
{ label: 'CLI path', expected: `-mount="${this.backend}" "${this.path}"` },
{
label: 'API path for metadata',
expected: `/v1/${backend}/metadata/${path}`,
},
];
await this.renderComponent();
for (const path of paths) {
assert.dom(PAGE.infoRowValue(path.label)).hasText(path.expected);
this.assertClipboard(assert, PAGE.paths.copyButton(path.label), path.expected);
}
});
test('it renders copyable commands', async function (assert) {
const url = `https://127.0.0.1:8200/v1/${this.backend}/data/${this.path}`;
const expected = {
cli: `vault kv get -mount="${this.backend}" "${this.path}"`,
api: `curl \\ --header \"X-Vault-Token: ...\" \\ --request GET \\ ${url}`,
};
await this.renderComponent();
assert.dom(PAGE.paths.codeSnippet('cli')).hasText(expected.cli);
assert.dom(PAGE.paths.codeSnippet('api')).hasText(expected.api);
});
test('it renders copyable encoded mount and path commands', async function (assert) {
this.path = `my spacey!"secret`;
this.backend = `my fancy!"backend`;
const backend = encodeURIComponent(this.backend);
const path = encodeURIComponent(this.path);
const url = `https://127.0.0.1:8200/v1/${backend}/data/${path}`;
const expected = {
cli: `vault kv get -mount="${this.backend}" "${this.path}"`,
api: `curl \\ --header \"X-Vault-Token: ...\" \\ --request GET \\ ${url}`,
};
await this.renderComponent();
assert.dom(PAGE.paths.codeSnippet('cli')).hasText(expected.cli);
assert.dom(PAGE.paths.codeSnippet('api')).hasText(expected.api);
});
});

View file

@ -0,0 +1,341 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { dateFormat } from 'core/helpers/date-format';
import { dateFromNow } from 'core/helpers/date-from-now';
import { baseSetup } from 'vault/tests/helpers/kv/kv-run-commands';
const { overviewCard } = GENERAL;
module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kv');
setupMirage(hooks);
hooks.beforeEach(async function () {
baseSetup(this);
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.backend, route: 'list' },
{ label: this.path },
];
this.subkeys = {
subkeys: {
foo: null,
bar: {
baz: null,
},
quux: null,
},
metadata: {
created_time: '2021-12-14T20:28:00.773477Z',
custom_metadata: null,
deletion_time: '',
destroyed: false,
version: 1,
},
};
this.canReadMetadata = true;
this.canUpdateSecret = true;
this.secretState = 'created';
this.format = (time) => dateFormat([time, 'MMM d yyyy, h:mm:ss aa'], {});
this.renderComponent = async () => {
return render(
hbs`
<Page::Secret::Overview
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
@canReadMetadata={{this.canReadMetadata}}
@canUpdateSecret={{this.canUpdateSecret}}
@metadata={{this.metadata}}
@path={{this.path}}
@secretState={{this.secretState}}
@subkeys={{this.subkeys}}
/>`,
{ owner: this.engine }
);
};
});
module('it renders when version is not deleted nor destroyed', function () {
test('it renders tabs', async function (assert) {
await this.renderComponent();
const tabs = ['Overview', 'Secret', 'Metadata', 'Paths', 'Version History'];
for (const tab of tabs) {
assert.dom(PAGE.secretTab(tab)).hasText(tab);
}
});
test('it renders header', async function (assert) {
await this.renderComponent();
assert.dom(PAGE.breadcrumbs).hasText(`Secrets ${this.backend} ${this.path}`);
assert.dom(PAGE.title).hasText(this.path);
});
test('it renders with full permissions', async function (assert) {
await this.renderComponent();
const fromNow = dateFromNow([this.metadata.createdTime]); // uses date-fns so can't stub timestamp util
assert.dom(`${overviewCard.container('Current version')} .hds-badge`).doesNotExist();
assert
.dom(overviewCard.container('Current version'))
.hasText(
`Current version Create new The current version of this secret. ${this.metadata.currentVersion}`
);
assert
.dom(overviewCard.container('Secret age'))
.hasText(
`Secret age View metadata Time since last update at ${this.format(
this.metadata.createdTime
)}. ${fromNow}`
);
assert
.dom(overviewCard.container('Paths'))
.hasText(
`Paths The paths to use when referring to this secret in API or CLI. API path /v1/${this.backend}/data/${this.path} CLI path -mount="${this.backend}" "${this.path}"`
);
assert
.dom(overviewCard.container('Subkeys'))
.hasText(
`Subkeys JSON The table is displaying the top level subkeys. Toggle on the JSON view to see the full depth. Keys ${Object.keys(
this.subkeys.subkeys
).join(' ')}`
);
});
test('it hides link when no secret update permissions', async function (assert) {
// creating a new version of a secret is updating a secret
// the overview only exists after an initial version is created
// which is why we just check for update and not also create
this.canUpdateSecret = false;
await this.renderComponent();
assert
.dom(`${overviewCard.container('Current version')} a`)
.doesNotExist('create link does not render');
assert
.dom(overviewCard.container('Current version'))
.hasText(`Current version The current version of this secret. ${this.metadata.currentVersion}`);
});
test('it renders with no metadata permissions', async function (assert) {
this.metadata = null;
this.canReadMetadata = false;
// all secret metadata instead comes from subkeys endpoint
const subkeyMeta = this.subkeys.metadata;
await this.renderComponent();
const fromNow = dateFromNow([subkeyMeta.created_time]); // uses date-fns so can't stub timestamp util
assert
.dom(overviewCard.container('Current version'))
.hasText(`Current version Create new The current version of this secret. ${subkeyMeta.version}`);
assert
.dom(overviewCard.container('Secret age'))
.hasText(`Secret age Time since last update at ${this.format(subkeyMeta.created_time)}. ${fromNow}`);
assert.dom(`${overviewCard.container('Secret age')} a`).doesNotExist('metadata link does not render');
assert
.dom(overviewCard.container('Paths'))
.hasText(
`Paths The paths to use when referring to this secret in API or CLI. API path /v1/${this.backend}/data/${this.path} CLI path -mount="${this.backend}" "${this.path}"`
);
assert
.dom(overviewCard.container('Subkeys'))
.hasText(
`Subkeys JSON The table is displaying the top level subkeys. Toggle on the JSON view to see the full depth. Keys ${Object.keys(
this.subkeys.subkeys
).join(' ')}`
);
});
test('it renders with no subkeys permissions', async function (assert) {
this.subkeys = null;
await this.renderComponent();
const fromNow = dateFromNow([this.metadata.createdTime]); // uses date-fns so can't stub timestamp util
const expectedTime = this.format(this.metadata.createdTime);
assert
.dom(overviewCard.container('Current version'))
.hasText(
`Current version Create new The current version of this secret. ${this.metadata.currentVersion}`
);
assert
.dom(overviewCard.container('Secret age'))
.hasText(`Secret age View metadata Time since last update at ${expectedTime}. ${fromNow}`);
assert
.dom(overviewCard.container('Paths'))
.hasText(
`Paths The paths to use when referring to this secret in API or CLI. API path /v1/${this.backend}/data/${this.path} CLI path -mount="${this.backend}" "${this.path}"`
);
assert.dom(overviewCard.container('Subkeys')).doesNotExist();
});
test('it renders with no subkey or metadata permissions', async function (assert) {
this.subkeys = null;
this.metadata = null;
await this.renderComponent();
assert.dom(overviewCard.container('Current version')).doesNotExist();
assert.dom(overviewCard.container('Secret age')).doesNotExist();
assert.dom(overviewCard.container('Subkeys')).doesNotExist();
assert
.dom(overviewCard.container('Paths'))
.hasText(
`Paths The paths to use when referring to this secret in API or CLI. API path /v1/${this.backend}/data/${this.path} CLI path -mount="${this.backend}" "${this.path}"`
);
});
});
module('it renders when version is deleted', function (hooks) {
hooks.beforeEach(async function () {
this.secretState = 'deleted';
// subkeys is null but metadata still has data
this.subkeys = {
subkeys: null,
metadata: {
created_time: '2021-12-14T20:28:00.773477Z',
custom_metadata: null,
deletion_time: '2022-02-14T20:28:00.773477Z',
destroyed: false,
version: 1,
},
};
this.metadata.versions[4].deletion_time = '2024-08-15T23:01:08.312332Z';
this.assertBadge = (assert) => {
assert
.dom(`${overviewCard.container('Current version')} .hds-badge`)
.hasClass('hds-badge--color-neutral');
assert
.dom(`${overviewCard.container('Current version')} .hds-badge`)
.hasClass('hds-badge--type-inverted');
assert.dom(`${overviewCard.container('Current version')} .hds-badge`).hasText('Deleted');
};
});
test('with full permissions', async function (assert) {
const expectedTime = this.format(this.metadata.versions[4].deletion_time);
await this.renderComponent();
this.assertBadge(assert);
assert
.dom(overviewCard.container('Current version'))
.hasText(
`Current version Deleted Create new The current version of this secret was deleted ${expectedTime}. ${this.metadata.currentVersion}`
);
assert.dom(overviewCard.container('Secret age')).doesNotExist();
assert.dom(overviewCard.container('Subkeys')).doesNotExist();
assert
.dom(overviewCard.container('Paths'))
.hasText(
`Paths The paths to use when referring to this secret in API or CLI. API path /v1/${this.backend}/data/${this.path} CLI path -mount="${this.backend}" "${this.path}"`
);
});
test('with no metadata permissions', async function (assert) {
this.metadata = null;
const expectedTime = this.format(this.subkeys.metadata.deletion_time);
await this.renderComponent();
this.assertBadge(assert);
assert
.dom(overviewCard.container('Current version'))
.hasText(
`Current version Deleted Create new The current version of this secret was deleted ${expectedTime}. ${this.subkeys.metadata.version}`
);
});
test('with no subkey permissions', async function (assert) {
this.subkeys = null;
const expectedTime = this.format(this.metadata.versions[4].deletion_time);
await this.renderComponent();
this.assertBadge(assert);
assert
.dom(overviewCard.container('Current version'))
.hasText(
`Current version Deleted Create new The current version of this secret was deleted ${expectedTime}. ${this.metadata.currentVersion}`
);
});
test('with no permissions', async function (assert) {
this.subkeys = null;
this.metadata = null;
await this.renderComponent();
assert.dom(overviewCard.container('Current version')).doesNotExist();
});
});
module('it renders when version is destroyed', function (hooks) {
hooks.beforeEach(async function () {
this.secretState = 'destroyed';
// subkeys is null but metadata still has data
this.subkeys = {
subkeys: null,
metadata: {
created_time: '2024-08-15T01:24:43.658478Z',
custom_metadata: null,
deletion_time: '',
destroyed: true,
version: 1,
},
};
this.metadata.versions[4].destroyed = true;
this.assertBadge = (assert) => {
assert
.dom(`${overviewCard.container('Current version')} .hds-badge`)
.hasClass('hds-badge--color-critical');
assert
.dom(`${overviewCard.container('Current version')} .hds-badge`)
.hasClass('hds-badge--type-outlined');
assert.dom(`${overviewCard.container('Current version')} .hds-badge`).hasText('Destroyed');
};
});
test('with full permissions', async function (assert) {
await this.renderComponent();
this.assertBadge(assert);
assert
.dom(overviewCard.container('Current version'))
.hasText(
`Current version Destroyed Create new The current version of this secret has been permanently deleted and cannot be restored. ${this.metadata.currentVersion}`
);
assert.dom(overviewCard.container('Secret age')).doesNotExist();
assert.dom(overviewCard.container('Subkeys')).doesNotExist();
assert
.dom(overviewCard.container('Paths'))
.hasText(
`Paths The paths to use when referring to this secret in API or CLI. API path /v1/${this.backend}/data/${this.path} CLI path -mount="${this.backend}" "${this.path}"`
);
});
test('with no metadata permissions', async function (assert) {
this.metadata = null;
await this.renderComponent();
this.assertBadge(assert);
assert
.dom(overviewCard.container('Current version'))
.hasText(
`Current version Destroyed Create new The current version of this secret has been permanently deleted and cannot be restored. ${this.subkeys.metadata.version}`
);
});
test('with no subkeys permissions', async function (assert) {
this.subkeys = null;
await this.renderComponent();
this.assertBadge(assert);
assert
.dom(overviewCard.container('Current version'))
.hasText(
`Current version Destroyed Create new The current version of this secret has been permanently deleted and cannot be restored. ${this.metadata.currentVersion}`
);
});
test('with no permissions', async function (assert) {
this.subkeys = null;
this.metadata = null;
await this.renderComponent();
assert.dom(overviewCard.container('Current version')).doesNotExist();
});
});
});

View file

@ -9,8 +9,6 @@ import { setupEngine } from 'ember-engines/test-support';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { PAGE } from 'vault/tests/helpers/kv/kv-selectors';
/* eslint-disable no-useless-escape */
module('Integration | Component | kv-v2 | Page::Secret::Paths', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kv');
@ -18,123 +16,55 @@ module('Integration | Component | kv-v2 | Page::Secret::Paths', function (hooks)
hooks.beforeEach(async function () {
this.backend = 'kv-engine';
this.path = 'my-secret';
this.canReadMetadata = true;
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.backend, route: 'list' },
{ label: this.path },
];
this.assertClipboard = (assert, element, expected) => {
assert.dom(element).hasAttribute('data-test-copy-button', expected);
this.renderComponent = async () => {
await render(
hbs`
<Page::Secret::Paths
@path={{this.path}}
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
@canReadMetadata={{this.canReadMetadata}}
/>
`,
{ owner: this.engine }
);
};
});
test('it renders copyable paths', async function (assert) {
assert.expect(6);
const paths = [
{ label: 'API path', expected: `/v1/${this.backend}/data/${this.path}` },
{ label: 'CLI path', expected: `-mount="${this.backend}" "${this.path}"` },
{ label: 'API path for metadata', expected: `/v1/${this.backend}/metadata/${this.path}` },
];
await render(
hbs`
<Page::Secret::Paths
@path={{this.path}}
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ owner: this.engine }
);
for (const path of paths) {
assert.dom(PAGE.infoRowValue(path.label)).hasText(path.expected);
this.assertClipboard(assert, PAGE.paths.copyButton(path.label), path.expected);
test('it renders tabs', async function (assert) {
await this.renderComponent();
const tabs = ['Secret', 'Metadata', 'Paths', 'Version History'];
for (const tab of tabs) {
assert.dom(PAGE.secretTab(tab)).hasText(tab);
}
});
test('it renders copyable encoded mount and secret paths', async function (assert) {
assert.expect(6);
this.path = `my spacey!"secret`;
this.backend = `my fancy!"backend`;
const backend = encodeURIComponent(this.backend);
const path = encodeURIComponent(this.path);
const paths = [
{
label: 'API path',
expected: `/v1/${backend}/data/${path}`,
},
{ label: 'CLI path', expected: `-mount="${this.backend}" "${this.path}"` },
{
label: 'API path for metadata',
expected: `/v1/${backend}/metadata/${path}`,
},
];
await render(
hbs`
<Page::Secret::Paths
@path={{this.path}}
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ owner: this.engine }
);
for (const path of paths) {
assert.dom(PAGE.infoRowValue(path.label)).hasText(path.expected);
this.assertClipboard(assert, PAGE.paths.copyButton(path.label), path.expected);
test('it hides version history when cannot READ metadata', async function (assert) {
this.canReadMetadata = false;
await this.renderComponent();
const tabs = ['Secret', 'Metadata', 'Paths'];
for (const tab of tabs) {
assert.dom(PAGE.secretTab(tab)).hasText(tab);
}
assert.dom(PAGE.secretTab('Version History')).doesNotExist();
});
test('it renders copyable commands', async function (assert) {
const url = `https://127.0.0.1:8200/v1/${this.backend}/data/${this.path}`;
const expected = {
cli: `vault kv get -mount="${this.backend}" "${this.path}"`,
api: `curl \\ --header \"X-Vault-Token: ...\" \\ --request GET \\ ${url}`,
};
await render(
hbs`
<Page::Secret::Paths
@path={{this.path}}
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ owner: this.engine }
);
assert.dom(PAGE.paths.codeSnippet('cli')).hasText(expected.cli);
assert.dom(PAGE.paths.codeSnippet('api')).hasText(expected.api);
test('it renders header', async function (assert) {
await this.renderComponent();
assert.dom(PAGE.breadcrumbs).hasText(`Secrets ${this.backend} ${this.path}`);
assert.dom(PAGE.title).hasText(this.path);
});
test('it renders copyable encoded mount and path commands', async function (assert) {
this.path = `my spacey!"secret`;
this.backend = `my fancy!"backend`;
const backend = encodeURIComponent(this.backend);
const path = encodeURIComponent(this.path);
const url = `https://127.0.0.1:8200/v1/${backend}/data/${path}`;
const expected = {
cli: `vault kv get -mount="${this.backend}" "${this.path}"`,
api: `curl \\ --header \"X-Vault-Token: ...\" \\ --request GET \\ ${url}`,
};
await render(
hbs`
<Page::Secret::Paths
@path={{this.path}}
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ owner: this.engine }
);
assert.dom(PAGE.paths.codeSnippet('cli')).hasText(expected.cli);
assert.dom(PAGE.paths.codeSnippet('api')).hasText(expected.api);
test('it renders commands which is the uncondensed version of KvPathsCard', async function (assert) {
await this.renderComponent();
assert.dom(PAGE.paths.codeSnippet('cli')).exists();
assert.dom(PAGE.paths.codeSnippet('api')).exists();
});
});

View file

@ -13,6 +13,7 @@ const ACTION_TEXT = 'View card';
const SUBTEXT = 'This is subtext for card';
const SELECTORS = {
container: '[data-test-overview-card-container]',
title: '[data-test-overview-card-title]',
subtitle: '[data-test-overview-card-subtitle]',
action: '[data-test-action-text]',
@ -28,10 +29,21 @@ module('Integration | Component | overview-card', function (hooks) {
this.set('subText', SUBTEXT);
});
test('it returns card title, ', async function (assert) {
test('it returns card title', async function (assert) {
await render(hbs`<OverviewCard @cardTitle={{this.cardTitle}}/>`);
assert.dom(SELECTORS.title).hasText('Card title');
});
test('it returns custom title if both exist', async function (assert) {
await render(hbs`
<OverviewCard @cardTitle={{this.cardTitle}}>
<:customTitle>
Fancy custom title
</:customTitle>
</OverviewCard>
`);
assert.dom(SELECTORS.container).hasText('Fancy custom title');
assert.dom(SELECTORS.container).doesNotIncludeText(this.cardTitle);
});
test('it renders card @subText arg, ', async function (assert) {
await render(hbs`<OverviewCard @cardTitle={{this.cardTitle}} @subText={{this.subText}} />`);
assert.dom(SELECTORS.subtitle).hasText('This is subtext for card');

View file

@ -10,7 +10,9 @@ import { hbs } from 'ember-cli-htmlbars';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { PKI_OVERVIEW } from 'vault/tests/helpers/pki/pki-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
const { overviewCard } = GENERAL;
module('Integration | Component | Page::PkiOverview', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'pki');
@ -39,9 +41,11 @@ module('Integration | Component | Page::PkiOverview', function (hooks) {
hbs`<Page::PkiOverview @issuers={{this.issuers}} @roles={{this.roles}} @engine={{this.engineId}} />,`,
{ owner: this.engine }
);
assert.dom(PKI_OVERVIEW.issuersCardTitle).hasText('Issuers');
assert.dom(PKI_OVERVIEW.issuersCardOverviewNum).hasText('2');
assert.dom(PKI_OVERVIEW.issuersCardLink).hasText('View issuers');
assert
.dom(overviewCard.container('Issuers'))
.hasText(
'Issuers View issuers The total number of issuers in this PKI mount. Includes both root and intermediate certificates. 2'
);
});
test('shows the correct information on roles card', async function (assert) {
@ -49,15 +53,21 @@ module('Integration | Component | Page::PkiOverview', function (hooks) {
hbs`<Page::PkiOverview @issuers={{this.issuers}} @roles={{this.roles}} @engine={{this.engineId}} />,`,
{ owner: this.engine }
);
assert.dom(PKI_OVERVIEW.rolesCardTitle).hasText('Roles');
assert.dom(PKI_OVERVIEW.rolesCardOverviewNum).hasText('3');
assert.dom(PKI_OVERVIEW.rolesCardLink).hasText('View roles');
assert
.dom(overviewCard.container('Roles'))
.hasText(
'Roles View roles The total number of roles in this PKI mount that have been created to generate certificates. 3'
);
this.roles = 404;
await render(
hbs`<Page::PkiOverview @issuers={{this.issuers}} @roles={{this.roles}} @engine={{this.engineId}} />,`,
{ owner: this.engine }
);
assert.dom(PKI_OVERVIEW.rolesCardOverviewNum).hasText('0');
assert
.dom(overviewCard.container('Roles'))
.hasText(
'Roles View roles The total number of roles in this PKI mount that have been created to generate certificates. 0'
);
});
test('shows the input search fields for View Certificates card', async function (assert) {
@ -65,7 +75,7 @@ module('Integration | Component | Page::PkiOverview', function (hooks) {
hbs`<Page::PkiOverview @issuers={{this.issuers}} @roles={{this.roles}} @engine={{this.engineId}} />,`,
{ owner: this.engine }
);
assert.dom(PKI_OVERVIEW.issueCertificate).hasText('Issue certificate');
assert.dom(overviewCard.title('Issue certificate')).hasText('Issue certificate');
assert.dom(PKI_OVERVIEW.issueCertificateInput).exists();
assert.dom(PKI_OVERVIEW.issueCertificateButton).hasText('Issue');
});
@ -75,7 +85,7 @@ module('Integration | Component | Page::PkiOverview', function (hooks) {
hbs`<Page::PkiOverview @issuers={{this.issuers}} @roles={{this.roles}} @engine={{this.engineId}} />,`,
{ owner: this.engine }
);
assert.dom(PKI_OVERVIEW.viewCertificate).hasText('View certificate');
assert.dom(overviewCard.title('View certificate')).hasText('View certificate');
assert.dom(PKI_OVERVIEW.viewCertificateInput).exists();
assert.dom(PKI_OVERVIEW.viewCertificateButton).hasText('View');
});

View file

@ -33,7 +33,7 @@ module('Integration | Component | SecretEngine/configuration-details', function
});
test('it shows config details if configModel(s) are passed in', async function (assert) {
assert.expect(14);
assert.expect(21);
for (const type of CONFIGURABLE_SECRET_ENGINES) {
const backend = `test-${type}`;
this.configModels = createConfig(this.store, backend, type);
@ -45,6 +45,12 @@ module('Integration | Component | SecretEngine/configuration-details', function
assert
.dom(GENERAL.infoRowValue(key))
.hasText(responseKeyAndValue, `${key} value for the ${type} config details exists.`);
// make sure the ones that should be masked are masked, and others are not.
if (key === 'private_key' || key === 'public_key') {
assert.dom(GENERAL.infoRowValue(key)).hasClass('masked-input', `${key} is masked`);
} else {
assert.dom(GENERAL.infoRowValue(key)).doesNotHaveClass('masked-input', `${key} is not masked`);
}
}
}
});

View file

@ -5,94 +5,145 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render, click } from '@ember/test-helpers';
import { render, click, fillIn } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors';
import { createConfig } from 'vault/tests/helpers/secret-engine/secret-engine-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import sinon from 'sinon';
module('Integration | Component | SecretEngine/configure-ssh', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.model = createConfig(this.store, 'ssh-test', 'ssh');
this.saveConfig = sinon.stub();
const router = this.owner.lookup('service:router');
this.id = 'ssh-test';
this.model = this.store.createRecord('ssh/ca-config', { backend: this.id });
this.transitionStub = sinon.stub(router, 'transitionTo');
this.refreshStub = sinon.stub(router, 'refresh');
});
test('it shows create fields if not configured', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureSsh
@model={{this.model}}
@configured={{false}}
@saveConfig={{this.saveConfig}}
@loading={{false}}
@id={{this.id}}
/>
`);
assert.dom(GENERAL.maskedInput('privateKey')).hasNoText('Private key is empty and reset');
assert.dom(GENERAL.inputByAttr('publicKey')).hasNoText('Public key is empty and reset');
assert
.dom(GENERAL.inputByAttr('generate-signing-key-checkbox'))
.dom(GENERAL.inputByAttr('generateSigningKey'))
.isChecked('Generate signing key is checked by default');
});
test('it calls save with correct arg', async function (assert) {
test('it should go back to parent route on cancel', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureSsh
@model={{this.model}}
@configured={{false}}
@saveConfig={{this.saveConfig}}
@loading={{false}}
@id={{this.id}}
/>
`);
await click(SES.ssh.cancel);
assert.true(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', 'ssh-test'),
'On cancel the router transitions to the parent configuration index route.'
);
});
test('it should validate form fields', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureSsh
@model={{this.model}}
@id={{this.id}}
/>
`);
await fillIn(GENERAL.inputByAttr('publicKey'), 'hello');
await click(SES.ssh.save);
assert.ok(
this.saveConfig.withArgs({ delete: false }).calledOnce,
'calls the saveConfig action with args delete:false'
);
assert
.dom(GENERAL.inlineError)
.hasText(
'You must provide a Public and Private keys or leave both unset.',
'Public key validation error renders.'
);
await click(GENERAL.inputByAttr('generateSigningKey'));
await click(SES.ssh.save);
assert
.dom(GENERAL.inlineError)
.hasText(
'You must provide a Public and Private keys or leave both unset.',
'Generate signing key validation message shows.'
);
});
test('it shows masked key if model is not new', async function (assert) {
// replace model with model that has public_key
this.model = {
publicKey:
'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC3lCZ7W2eJZ9W9qzv7K9GJ5qJYQ2cY6C+5Kv8Jtjz8h6wqZJ9U9K1lJ9Z6zq4sX0f7Q5X2l8L4gTt2+2ZKpVv6g1KQ6JG5H4QbVrQq2r4FzZQ2B0Y8q5c7q3Y5X6q4Q6',
};
test('it should generate signing key', async function (assert) {
assert.expect(2);
this.server.post('/ssh-test/config/ca', (schema, req) => {
const data = JSON.parse(req.requestBody);
const expected = {
backend: this.id,
generate_signing_key: true,
};
assert.deepEqual(expected, data, 'POST request made to save ca-config with correct properties');
});
await render(hbs`
<SecretEngine::ConfigureSsh
@model={{this.model}}
@configured={{true}}
@saveConfig={{this.saveConfig}}
@loading={{false}}
@id={{this.id}}
/>
`);
assert
.dom(SES.ssh.editConfigSection)
.exists('renders the edit configuration section of the form and not the create part');
assert.dom(GENERAL.inputByAttr('public-key')).hasText('***********', 'public key is masked');
await click('[data-test-button="toggle-masked"]');
assert
.dom(GENERAL.inputByAttr('public-key'))
.hasText(this.model.publicKey, 'public key is unmasked and shows the actual value');
await click(SES.ssh.save);
assert.dom(SES.ssh.editConfigSection).exists('renders the edit configuration section of the form');
});
test('it calls delete correctly', async function (assert) {
await render(hbs`
module('editing', function (hooks) {
hooks.beforeEach(function () {
this.editId = 'ssh-edit-me';
this.editModel = createConfig(this.store, 'ssh-edit-me', 'ssh');
});
test('it populates fields when editing', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureSsh
@model={{this.model}}
@configured={{true}}
@saveConfig={{this.saveConfig}}
@loading={{false}}
@model={{this.editModel}}
@id={{this.editId}}
/>
`);
// delete Public key
await click(SES.ssh.deletePublicKey);
assert.dom(GENERAL.confirmMessage).hasText('This will remove the CA certificate information.');
await click(GENERAL.confirmButton);
assert.ok(
this.saveConfig.withArgs({ delete: true }).calledOnce,
'calls the saveConfig action with args delete:true'
);
assert
.dom(SES.ssh.editConfigSection)
.exists('renders the edit configuration section of the form and not the create part');
assert.dom(GENERAL.inputByAttr('public-key')).hasText('***********', 'public key is masked');
await click('[data-test-button="toggle-masked"]');
assert
.dom(GENERAL.inputByAttr('public-key'))
.hasText(this.editModel.publicKey, 'public key is unmasked and shows the actual value');
});
test('it allows you to delete a public key', async function (assert) {
assert.expect(3);
this.server.delete('/ssh-edit-me/config/ca', () => {
assert.true(true, 'DELETE request made to ca-config with correct properties');
});
await render(hbs`
<SecretEngine::ConfigureSsh
@model={{this.editModel}}
@id={{this.editId}}
/>
`);
// delete Public key
await click(SES.ssh.delete);
assert.dom(GENERAL.confirmMessage).hasText('Confirming will remove the CA certificate information.');
await click(GENERAL.confirmButton);
assert.true(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration.edit', 'ssh-edit-me'),
'On delete the router transitions to the current route.'
);
});
});
});

View file

@ -358,7 +358,7 @@ module('Integration | Component | sync | Page::Overview', function (hooks) {
test('it should show the Totals cards', async function (assert) {
await this.renderComponent();
const { title, description, action, content } = overviewCard;
const { title, description, actionLink, content } = overviewCard;
const cardData = [
{
cardTitle: 'Total destinations',
@ -379,7 +379,7 @@ module('Integration | Component | sync | Page::Overview', function (hooks) {
assert.dom(title(cardTitle)).hasText(cardTitle, `${cardTitle} card title renders`);
assert.dom(description(cardTitle)).hasText(subText, ` ${cardTitle} card description renders`);
assert.dom(content(cardTitle)).hasText(count, 'Total count renders');
assert.dom(action(cardTitle)).hasText(actionText, 'Card action renders');
assert.dom(actionLink(cardTitle)).hasText(actionText, 'Card action renders');
});
});
});

View file

@ -8,6 +8,7 @@ import { setupRenderingTest } from 'ember-qunit';
import { find, render, settled } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import { formatTimeZone } from 'core/helpers/date-format';
import { isMatch } from 'date-fns';
const TEST_DATE = new Date('2018-04-03T14:15:30');
@ -56,6 +57,14 @@ module('Integration | Helper | date-format', function (hooks) {
assert.strictEqual(resultLengthWithTimezone - 4, 4, 'Adds 4 characters for timezone');
});
test('it renders default format', async function (assert) {
this.set('timestampDate', TEST_DATE);
await render(hbs`<span data-test-formatted>{{date-format this.timestampDate}}</span>`);
const value = find('[data-test-formatted]').innerText;
const format = 'MMM d yyyy, h:mm:ss aa';
assert.true(isMatch(value, format), `${value} is formatted as ${format}`);
});
test('fails gracefully if given a non-date value', async function (assert) {
this.set('value', 'not a date');

View file

@ -6,7 +6,7 @@
import { module, test } from 'qunit';
import { subMinutes } from 'date-fns';
import { setupRenderingTest } from 'ember-qunit';
import { dateFromNow } from '../../../helpers/date-from-now';
import { dateFromNow } from 'core/helpers/date-from-now';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';

View file

@ -4,62 +4,146 @@
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { render, click, fillIn } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors';
import { createConfig } from 'vault/tests/helpers/secret-engine/secret-engine-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import sinon from 'sinon';
module('Unit | Adapter | secret engine', function (hooks) {
setupTest(hooks);
module('Integration | Component | SecretEngine/configure-ssh', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
const storeStub = {
serializerFor() {
return {
serializeIntoHash() {},
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
const router = this.owner.lookup('service:router');
this.id = 'ssh-test';
this.model = this.store.createRecord('ssh/ca-config', { backend: this.id });
this.transitionStub = sinon.stub(router, 'transitionTo');
this.refreshStub = sinon.stub(router, 'refresh');
});
test('it shows create fields if not configured', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureSsh
@model={{this.model}}
@id={{this.id}}
/>
`);
assert.dom(GENERAL.maskedInput('privateKey')).hasNoText('Private key is empty and reset');
assert.dom(GENERAL.inputByAttr('publicKey')).hasNoText('Public key is empty and reset');
assert
.dom(GENERAL.inputByAttr('generateSigningKey'))
.isChecked('Generate signing key is checked by default');
});
test('it should go back to parent route on cancel', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureSsh
@model={{this.model}}
@id={{this.id}}
/>
`);
await click(SES.ssh.cancel);
assert.true(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration', 'ssh-test'),
'On cancel the router transitions to the parent configuration index route.'
);
});
test('it should validate form fields', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureSsh
@model={{this.model}}
@id={{this.id}}
/>
`);
await fillIn(GENERAL.inputByAttr('publicKey'), 'hello');
await click(SES.ssh.save);
assert
.dom(GENERAL.inlineError)
.hasText(
'You must provide a Public and Private keys or leave both unset.',
'Public key validation error renders.'
);
await click(GENERAL.inputByAttr('generateSigningKey'));
await click(SES.ssh.save);
assert
.dom(GENERAL.inlineError)
.hasText(
'You must provide a Public and Private keys or leave both unset.',
'Generate signing key validation message shows.'
);
});
test('it should generate signing key', async function (assert) {
assert.expect(2);
this.server.post('/ssh-test/config/ca', (schema, req) => {
const data = JSON.parse(req.requestBody);
const expected = {
backend: this.id,
generate_signing_key: true,
};
},
};
const type = {
modelName: 'secret-engine',
};
assert.deepEqual(expected, data, 'POST request made to save ca-config with correct properties');
});
await render(hbs`
<SecretEngine::ConfigureSsh
@model={{this.model}}
@id={{this.id}}
/>
`);
test('Empty query', function (assert) {
assert.expect(1);
this.server.get('/sys/internal/ui/mounts', () => {
assert.ok('query calls the correct url');
return {};
});
const adapter = this.owner.lookup('adapter:secret-engine');
adapter['query'](storeStub, type, {});
});
test('Query with a path', function (assert) {
assert.expect(1);
this.server.get('/sys/internal/ui/mounts/foo', () => {
assert.ok('query calls the correct url');
return {};
});
const adapter = this.owner.lookup('adapter:secret-engine');
adapter['query'](storeStub, type, { path: 'foo' });
await click(SES.ssh.save);
assert.dom(SES.ssh.editConfigSection).exists('renders the edit configuration section of the form');
});
test('Query with nested path', function (assert) {
assert.expect(1);
this.server.get('/sys/internal/ui/mounts/foo/bar/baz', () => {
assert.ok('query calls the correct url');
return {};
module('editing', function (hooks) {
hooks.beforeEach(function () {
this.editId = 'ssh-edit-me';
this.editModel = createConfig(this.store, 'ssh-edit-me', 'ssh');
});
test('it populates fields when editing', async function (assert) {
await render(hbs`
<SecretEngine::ConfigureSsh
@model={{this.editModel}}
@id={{this.editId}}
/>
`);
assert
.dom(SES.ssh.editConfigSection)
.exists('renders the edit configuration section of the form and not the create part');
assert.dom(GENERAL.inputByAttr('public-key')).hasText('***********', 'public key is masked');
await click('[data-test-button="toggle-masked"]');
assert
.dom(GENERAL.inputByAttr('public-key'))
.hasText(this.editModel.publicKey, 'public key is unmasked and shows the actual value');
});
const adapter = this.owner.lookup('adapter:secret-engine');
adapter['query'](storeStub, type, { path: 'foo/bar/baz' });
});
test('Fails gracefully finding records for non ssh engines', function (assert) {
assert.expect(1);
const snapshot = {
attr() {
return { type: 'aws', path: 'aws/' };
},
};
const adapter = this.owner.lookup('adapter:secret-engine');
const response = adapter.findRecord(storeStub, 'aws', { path: 'aws' }, snapshot);
assert.propEqual(response, { data: {} }, 'returns empty data object');
test('it allows you to delete a public key', async function (assert) {
assert.expect(3);
this.server.delete('/ssh-edit-me/config/ca', () => {
assert.true(true, 'DELETE request made to ca-config with correct properties');
});
await render(hbs`
<SecretEngine::ConfigureSsh
@model={{this.editModel}}
@id={{this.editId}}
/>
`);
// delete Public key
await click(SES.ssh.delete);
assert.dom(GENERAL.confirmMessage).hasText('Confirming will remove the CA certificate information.');
await click(GENERAL.confirmButton);
assert.true(
this.transitionStub.calledWith('vault.cluster.secrets.backend.configuration.edit', 'ssh-edit-me'),
'On delete the router transitions to the current route.'
);
});
});
});

View file

@ -346,80 +346,6 @@ module('Unit | Model | secret-engine', function (hooks) {
});
});
module('saveCA', function () {
test('does not call endpoint if type != ssh', async function (assert) {
assert.expect(1);
const model = this.store.createRecord('secret-engine', {
type: 'not-ssh',
});
const saveSpy = sinon.spy(model, 'save');
await model.saveCA({});
assert.ok(saveSpy.notCalled, 'save not called');
});
test('calls save with correct params', async function (assert) {
assert.expect(4);
const model = this.store.createRecord('secret-engine', {
type: 'ssh',
privateKey: 'private-key',
publicKey: 'public-key',
generateSigningKey: true,
});
const saveStub = sinon.stub(model, 'save').callsFake((params) => {
assert.deepEqual(
params,
{
adapterOptions: {
options: {},
apiPath: 'config/ca',
attrsToSend: ['privateKey', 'publicKey', 'generateSigningKey'],
},
},
'send correct params to save'
);
return;
});
await model.saveCA({});
assert.strictEqual(model.privateKey, 'private-key', 'value exists before save');
assert.strictEqual(model.publicKey, 'public-key', 'value exists before save');
assert.true(model.generateSigningKey, 'value true before save');
saveStub.restore();
});
test('sets properties when isDelete', async function (assert) {
assert.expect(7);
const model = this.store.createRecord('secret-engine', {
type: 'ssh',
privateKey: 'private-key',
publicKey: 'public-key',
generateSigningKey: true,
});
const saveStub = sinon.stub(model, 'save').callsFake((params) => {
assert.deepEqual(
params,
{
adapterOptions: {
options: { isDelete: true },
apiPath: 'config/ca',
attrsToSend: ['privateKey', 'publicKey', 'generateSigningKey'],
},
},
'send correct params to save'
);
return;
});
assert.strictEqual(model.privateKey, 'private-key', 'value exists before save');
assert.strictEqual(model.publicKey, 'public-key', 'value exists before save');
assert.true(model.generateSigningKey, 'value true before save');
await model.saveCA({ isDelete: true });
assert.strictEqual(model.privateKey, null, 'value null after save');
assert.strictEqual(model.publicKey, null, 'value null after save');
assert.false(model.generateSigningKey, 'value false after save');
saveStub.restore();
});
});
module('saveZeroAddressConfig', function () {
test('calls save with correct params', async function (assert) {
assert.expect(1);

View file

@ -13,6 +13,14 @@ The format of this file is [HCL](https://github.com/hashicorp/hcl) or JSON.
An example configuration is shown below:
<Note>
For multi-node clusters, replace the loopback address with a valid, routable IP address for each Vault node in your network.
Refer to the [Vault HA clustering with integrated storage tutorial](/vault/tutorials/raft/raft-storage) for a complete scenario.
</Note>
```hcl
ui = true
cluster_addr = "https://127.0.0.1:8201"
@ -116,10 +124,14 @@ to specify where the configuration is.
sudo setcap cap_ipc_lock=+ep $(readlink -f $(which vault))
```
~> Note: Since each plugin runs as a separate process, you need to do the same
<Note>
Since each plugin runs as a separate process, you need to do the same
for each plugin in your [plugins
directory](/vault/docs/plugins/plugin-architecture#plugin-directory).
</Note>
If you use a Linux distribution with a modern version of systemd, you can add
the following directive to the "[Service]" configuration section:
@ -211,12 +223,20 @@ can have a negative effect on performance due to the tracking of each lock attem
Supported values (in order of descending detail) are `trace`, `debug`, `info`, `warn`, and `error`.
This can also be specified via the `VAULT_LOG_LEVEL` environment variable.
~> Note: On SIGHUP (`sudo kill -s HUP` _pid of vault_), if a valid value is specified, Vault will update the existing log level,
<Note>
On SIGHUP (`sudo kill -s HUP` _pid of vault_), if a valid value is specified, Vault will update the existing log level,
overriding (even if specified) both the CLI flag and environment variable.
~> Note: Not all parts of Vault's logging can have its log level be changed dynamically this way; in particular,
</Note>
<Note>
Not all parts of Vault's logging can have its log level be changed dynamically this way; in particular,
secrets/auth plugins are currently not updated dynamically.
</Note>
- `log_format` - Equivalent to the [`-log-format` command-line flag](/vault/docs/commands/server#_log_format).
- `log_file` - Equivalent to the [`-log-file` command-line flag](/vault/docs/commands/server#_log_file).

View file

@ -83,14 +83,15 @@ management tool.
If a trust relationship exists between Vault and Azure through WIF, the secrets
engine can exchange the Vault identity token for a federated access token.
To configure a trusted relationship between Vault and Azure, :
- You must configure the [identity token issuer backend](/vault/api-docs/secret/identity/tokens#configure-the-identity-tokens-backend)
for Vault.
- Azure must have a
[federated identity credential](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-create-trust?pivots=identity-wif-apps-methods-azp#configure-a-federated-identity-credential-on-an-app)
configured with information about the fully qualified and network-reachable
issuer URL for the Vault plugin
[identity token provider](/vault/api-docs/secret/identity/tokens#read-plugin-identity-well-known-configurations).
To configure a trusted relationship between Vault and Azure:
- You must configure the [identity token issuer backend](/vault/api-docs/secret/identity/tokens#configure-the-identity-tokens-backend)
for Vault.
- Azure must have a
[federated identity credential](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-create-trust?pivots=identity-wif-apps-methods-azp#configure-a-federated-identity-credential-on-an-app)
configured with information about the fully qualified and network-reachable
issuer URL for the Vault plugin
[identity token provider](/vault/api-docs/secret/identity/tokens#read-plugin-identity-well-known-configurations).
Establishing a trusted relationship between Vault and Azure ensures that Azure
can fetch JWKS