diff --git a/.github/actions/build-vault/action.yml b/.github/actions/build-vault/action.yml index 8fc228415a02..5e2641344208 100644 --- a/.github/actions/build-vault/action.yml +++ b/.github/actions/build-vault/action.yml @@ -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 }} diff --git a/.github/actions/set-up-go/action.yml b/.github/actions/set-up-go/action.yml index 9a80bf32f497..548555d26693 100644 --- a/.github/actions/set-up-go/action.yml +++ b/.github/actions/set-up-go/action.yml @@ -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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a7edd5df97e4..1ab18b15ebe5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -387,7 +387,7 @@ jobs: with: version: ${{ needs.setup.outputs.vault-version-metadata }} product: ${{ needs.setup.outputs.vault-binary-name }} - - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + - uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 if: steps.generate-metadata-file.outcome == 'success' # upload our metadata if we created it with: name: metadata.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a958b2589760..e3859061aa6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,7 +207,7 @@ jobs: mkdir -p test-results/qunit yarn ${{ needs.setup.outputs.is-enterprise == 'true' && 'test' || 'test:oss' }} - if: always() - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: test-results-ui path: ui/test-results diff --git a/.github/workflows/plugin-update-check.yml b/.github/workflows/plugin-update-check.yml index c7ef203efc07..36bc0d27e7df 100644 --- a/.github/workflows/plugin-update-check.yml +++ b/.github/workflows/plugin-update-check.yml @@ -29,7 +29,7 @@ jobs: # https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow token: ${{ secrets.ELEVATED_GITHUB_TOKEN }} - - uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: cache: false # save cache space for vault builds: https://github.com/hashicorp/vault/pull/21764 go-version-file: .go-version diff --git a/.github/workflows/plugin-update.yml b/.github/workflows/plugin-update.yml index c63ec724e7de..55469104f01e 100644 --- a/.github/workflows/plugin-update.yml +++ b/.github/workflows/plugin-update.yml @@ -34,7 +34,7 @@ jobs: # https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow token: ${{ secrets.ELEVATED_GITHUB_TOKEN }} - - uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: cache: false # save cache space for vault builds: https://github.com/hashicorp/vault/pull/21764 go-version-file: .go-version diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 078ddb9471cb..a396ed9314d2 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -16,17 +16,17 @@ 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: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Set up Go - uses: actions/setup-go@cdcb36043654635271a94b9a6d1392de5bb323a7 # v5.0.1 + uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: cache: false # save cache space for vault builds: https://github.com/hashicorp/vault/pull/21764 go-version-file: .go-version diff --git a/.github/workflows/test-go.yml b/.github/workflows/test-go.yml index 8f04208f4ecf..67be67784f46 100644 --- a/.github/workflows/test-go.yml +++ b/.github/workflows/test-go.yml @@ -478,7 +478,7 @@ jobs: run: | tar -cvf '${{ steps.metadata.outputs.go-test-log-archive-name }}' -C "${{ steps.metadata.outputs.go-test-log-dir }}" . - name: Upload test logs archives - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: ${{ steps.metadata.outputs.go-test-log-archive-name }} path: ${{ steps.metadata.outputs.go-test-log-archive-name }} @@ -486,7 +486,7 @@ jobs: if: success() || failure() - name: Upload test results if: success() || failure() - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: ${{ steps.metadata.outputs.go-test-results-upload-key }} path: | @@ -526,7 +526,7 @@ jobs: if: | (success() || failure()) && steps.data-race-check.outputs.data-race-result == 'failure' - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: ${{ steps.metadata.outputs.data-race-log-upload-key }} path: ${{ steps.metadata.outputs.go-test-dir }}/${{ steps.metadata.outputs.data-race-log-file }} @@ -599,7 +599,7 @@ jobs: '${{ steps.metadata.outputs.gotestsum-timing-events }}' \ >> '${{ steps.metadata.outputs.failure-summary-file-name }}' - name: Upload failure summary - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 if: success() || failure() with: name: ${{ steps.metadata.outputs.failure-summary-file-name }} diff --git a/.github/workflows/test-run-acc-tests-for-path.yml b/.github/workflows/test-run-acc-tests-for-path.yml index 1e2a3bfd27bf..7385905a2670 100644 --- a/.github/workflows/test-run-acc-tests-for-path.yml +++ b/.github/workflows/test-run-acc-tests-for-path.yml @@ -25,7 +25,7 @@ jobs: with: github-token: ${{ secrets.ELEVATED_GITHUB_TOKEN }} - run: go test -v ./${{ inputs.path }}/... 2>&1 | tee ${{ inputs.name }}.txt - - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + - uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: ${{ inputs.name }}-output path: ${{ inputs.name }}.txt diff --git a/.github/workflows/test-run-enos-scenario-matrix.yml b/.github/workflows/test-run-enos-scenario-matrix.yml index acca9cbd5f4f..7b2473862908 100644 --- a/.github/workflows/test-run-enos-scenario-matrix.yml +++ b/.github/workflows/test-run-enos-scenario-matrix.yml @@ -155,7 +155,7 @@ jobs: run: enos scenario launch --timeout 60m0s --chdir ./enos ${{ matrix.scenario.id.filter }} - name: Upload Debug Data if: failure() - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: # The name of the artifact is the same as the matrix scenario name with the spaces replaced with underscores and colons replaced by equals. name: ${{ steps.prepare_scenario.outputs.debug_data_artifact_name }} diff --git a/audit/broker.go b/audit/broker.go index c6f9d0e3de22..601831eab067 100644 --- a/audit/broker.go +++ b/audit/broker.go @@ -305,7 +305,7 @@ func (b *Broker) LogRequest(ctx context.Context, in *logical.LogInput) (retErr e if hasAuditPipelines(b.broker) { status, err = b.broker.Send(auditContext, event.AuditType.AsEventType(), e) if err != nil { - return fmt.Errorf("%w: %w", err, errors.Join(status.Warnings...)) + return errors.Join(append([]error{err}, status.Warnings...)...) } } @@ -389,7 +389,7 @@ func (b *Broker) LogResponse(ctx context.Context, in *logical.LogInput) (retErr if hasAuditPipelines(b.broker) { status, err = b.broker.Send(auditContext, event.AuditType.AsEventType(), e) if err != nil { - return fmt.Errorf("%w: %w", err, errors.Join(status.Warnings...)) + return errors.Join(append([]error{err}, status.Warnings...)...) } } diff --git a/changelog/27457.txt b/changelog/27457.txt new file mode 100644 index 000000000000..e3cf89a76524 --- /dev/null +++ b/changelog/27457.txt @@ -0,0 +1,3 @@ +```release-note:improvement +sdk/helper: Allow setting environment variables when using NewTestDockerCluster +``` diff --git a/sdk/helper/testcluster/docker/environment.go b/sdk/helper/testcluster/docker/environment.go index fd1c11ffee9b..8dd40904f7d9 100644 --- a/sdk/helper/testcluster/docker/environment.go +++ b/sdk/helper/testcluster/docker/environment.go @@ -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 diff --git a/sdk/helper/testcluster/docker/environment_test.go b/sdk/helper/testcluster/docker/environment_test.go new file mode 100644 index 000000000000..bb2376405281 --- /dev/null +++ b/sdk/helper/testcluster/docker/environment_test.go @@ -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 +} diff --git a/ui/app/models/kv/metadata.js b/ui/app/models/kv/metadata.js index 66d1d1b8c0df..c5986518219b 100644 --- a/ui/app/models/kv/metadata.js +++ b/ui/app/models/kv/metadata.js @@ -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, }; } diff --git a/ui/lib/core/addon/components/json-editor.js b/ui/lib/core/addon/components/json-editor.js index 749e76ea7e31..14624f8124c3 100644 --- a/ui/lib/core/addon/components/json-editor.js +++ b/ui/lib/core/addon/components/json-editor.js @@ -16,6 +16,7 @@ import { obfuscateData } from 'core/utils/advanced-secret'; * * * @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. diff --git a/ui/lib/core/addon/components/overview-card.hbs b/ui/lib/core/addon/components/overview-card.hbs index 7bf6d32d176c..d497a218cb68 100644 --- a/ui/lib/core/addon/components/overview-card.hbs +++ b/ui/lib/core/addon/components/overview-card.hbs @@ -10,10 +10,14 @@ data-test-overview-card-container={{@cardTitle}} ...attributes > -
- - {{@cardTitle}} - +
+ {{#if (has-block "customTitle")}} + {{yield to="customTitle"}} + {{else}} + + {{@cardTitle}} + + {{/if}} {{#if (has-block "action")}} {{yield to="action"}} diff --git a/ui/lib/core/addon/helpers/date-format.js b/ui/lib/core/addon/helpers/date-format.js index 1084c2af99ff..c541a58fb8bd 100644 --- a/ui/lib/core/addon/helpers/date-format.js +++ b/ui/lib/core/addon/helpers/date-format.js @@ -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)) { diff --git a/ui/app/helpers/date-from-now.js b/ui/lib/core/addon/helpers/date-from-now.js similarity index 100% rename from ui/app/helpers/date-from-now.js rename to ui/lib/core/addon/helpers/date-from-now.js diff --git a/ui/lib/core/app/helpers/date-from-now.js b/ui/lib/core/app/helpers/date-from-now.js new file mode 100644 index 000000000000..64cb81592449 --- /dev/null +++ b/ui/lib/core/app/helpers/date-from-now.js @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +export { default } from 'core/helpers/date-from-now'; diff --git a/ui/lib/kv/addon/components/kv-paths-card.hbs b/ui/lib/kv/addon/components/kv-paths-card.hbs new file mode 100644 index 000000000000..d7976d92f27c --- /dev/null +++ b/ui/lib/kv/addon/components/kv-paths-card.hbs @@ -0,0 +1,82 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
+ + Paths + + {{#if @isCondensed}} + + The paths to use when referring to this secret in API or CLI. + + {{/if}} + +
+ {{#each this.paths as |path|}} + + + + {{path.snippet}} + + + {{/each}} +
+ + {{#unless @isCondensed}} + + Commands + + +
+

+ CLI + +

+

+ This command retrieves the value from KV secrets engine at the given key name. See our + + documentation + for other CLI commands. +

+ + +

+ API read secret version +

+

+ 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, + + learn more. + +

+ +
+ {{/unless}} +
\ No newline at end of file diff --git a/ui/lib/kv/addon/components/page/secret/paths.js b/ui/lib/kv/addon/components/kv-paths-card.js similarity index 67% rename from ui/lib/kv/addon/components/page/secret/paths.js rename to ui/lib/kv/addon/components/kv-paths-card.js index 716c76c3982f..008efa8f2b01 100644 --- a/ui/lib/kv/addon/components/page/secret/paths.js +++ b/ui/lib/kv/addon/components/kv-paths-card.js @@ -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. * - * * * @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.`, + }, + ]), ]; } diff --git a/ui/lib/kv/addon/components/kv-subkeys.hbs b/ui/lib/kv/addon/components/kv-subkeys.hbs index a36f18be69fa..62451b7368e0 100644 --- a/ui/lib/kv/addon/components/kv-subkeys.hbs +++ b/ui/lib/kv/addon/components/kv-subkeys.hbs @@ -3,9 +3,9 @@ SPDX-License-Identifier: BUSL-1.1 ~}} - + <:customSubtext> - + {{#if this.showJson}} These are the subkeys within this secret. All underlying values of leaf keys are not retrieved and are replaced with null diff --git a/ui/lib/kv/addon/components/page/secret/overview.hbs b/ui/lib/kv/addon/components/page/secret/overview.hbs new file mode 100644 index 000000000000..43da56c1aaf9 --- /dev/null +++ b/ui/lib/kv/addon/components/page/secret/overview.hbs @@ -0,0 +1,105 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + + + <:tabLinks> +
  • + Overview +
  • +
  • + Secret +
  • +
  • + Metadata +
  • +
  • + Paths +
  • + {{#if @canReadMetadata}} +
  • + Version History +
  • + {{/if}} + +
    + +{{#if (or @metadata @subkeys)}} +
    + + <:customTitle> + + Current version + {{#unless this.isActive}} + + {{/unless}} + + + <:action> + {{#if @canUpdateSecret}} + + {{/if}} + + <:content> + + {{or @metadata.currentVersion @subkeys.metadata.version}} + + + + + {{#if this.isActive}} + {{#let (or @metadata.createdTime @subkeys.metadata.created_time) as |timestamp|}} + + <:action> + {{#if @canReadMetadata}} + + {{/if}} + + <:content> + + {{date-from-now timestamp}} + + + + {{/let}} + {{/if}} +
    +{{/if}} + + + + + +{{#if @subkeys.subkeys}} + +{{/if}} \ No newline at end of file diff --git a/ui/lib/kv/addon/components/page/secret/overview.js b/ui/lib/kv/addon/components/page/secret/overview.js new file mode 100644 index 000000000000..0c41df40dc8e --- /dev/null +++ b/ui/lib/kv/addon/components/page/secret/overview.js @@ -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 + * + * + * @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.'; + } +} diff --git a/ui/lib/kv/addon/components/page/secret/paths.hbs b/ui/lib/kv/addon/components/page/secret/paths.hbs index 0472d30bc20d..f7e7e0c15dcc 100644 --- a/ui/lib/kv/addon/components/page/secret/paths.hbs +++ b/ui/lib/kv/addon/components/page/secret/paths.hbs @@ -36,66 +36,4 @@ -

    - Paths -

    - -
    - {{#each this.paths as |path|}} - - - - {{path.snippet}} - - - {{/each}} -
    - -

    - Commands -

    - -
    -

    - CLI - -

    -

    - This command retrieves the value from KV secrets engine at the given key name. For other CLI commands, - - learn more. - -

    - - -

    - API read secret version -

    -

    - 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, - - learn more. - -

    - -
    \ No newline at end of file + \ No newline at end of file diff --git a/ui/lib/pki/addon/components/page/pki-overview.hbs b/ui/lib/pki/addon/components/page/pki-overview.hbs index 6372e9844424..21b58e47f7a2 100644 --- a/ui/lib/pki/addon/components/page/pki-overview.hbs +++ b/ui/lib/pki/addon/components/page/pki-overview.hbs @@ -18,7 +18,7 @@ /> <:content> - + {{format-number (if (eq @issuers 404) 0 @issuers.length)}} @@ -38,7 +38,7 @@ /> <:content> - + {{format-number (if (eq @roles 404) 0 @roles.length)}} diff --git a/ui/tests/acceptance/pki/pki-overview-test.js b/ui/tests/acceptance/pki/pki-overview-test.js index 4281e1a1e9d1..cae35d6be479 100644 --- a/ui/tests/acceptance/pki/pki-overview-test.js +++ b/ui/tests/acceptance/pki/pki-overview-test.js @@ -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) { diff --git a/ui/tests/acceptance/sync/secrets/overview-test.js b/ui/tests/acceptance/sync/secrets/overview-test.js index c7070e7c6e1f..b36d112a7d19 100644 --- a/ui/tests/acceptance/sync/secrets/overview-test.js +++ b/ui/tests/acceptance/sync/secrets/overview-test.js @@ -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)); diff --git a/ui/tests/helpers/general-selectors.ts b/ui/tests/helpers/general-selectors.ts index dec99d129751..af135def1652 100644 --- a/ui/tests/helpers/general-selectors.ts +++ b/ui/tests/helpers/general-selectors.ts @@ -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', diff --git a/ui/tests/helpers/kv/kv-run-commands.js b/ui/tests/helpers/kv/kv-run-commands.js index 9b4e6a057120..a25886189b79 100644 --- a/ui/tests/helpers/kv/kv-run-commands.js +++ b/ui/tests/helpers/kv/kv-run-commands.js @@ -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); +}; diff --git a/ui/tests/helpers/pki/pki-selectors.ts b/ui/tests/helpers/pki/pki-selectors.ts index 09c0e2039eb9..2bd7bd320e99 100644 --- a/ui/tests/helpers/pki/pki-selectors.ts +++ b/ui/tests/helpers/pki/pki-selectors.ts @@ -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"]', diff --git a/ui/tests/integration/components/kv/kv-paths-card-test.js b/ui/tests/integration/components/kv/kv-paths-card-test.js new file mode 100644 index 000000000000..b29a0ee8bc86 --- /dev/null +++ b/ui/tests/integration/components/kv/kv-paths-card-test.js @@ -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``, + { + 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); + }); +}); diff --git a/ui/tests/integration/components/kv/page/kv-page-overview-test.js b/ui/tests/integration/components/kv/page/kv-page-overview-test.js new file mode 100644 index 000000000000..944c19f8f9be --- /dev/null +++ b/ui/tests/integration/components/kv/page/kv-page-overview-test.js @@ -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` + `, + { 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(); + }); + }); +}); diff --git a/ui/tests/integration/components/kv/page/kv-page-secret-paths-test.js b/ui/tests/integration/components/kv/page/kv-page-secret-paths-test.js index fae78bb41be7..8b1766e8036b 100644 --- a/ui/tests/integration/components/kv/page/kv-page-secret-paths-test.js +++ b/ui/tests/integration/components/kv/page/kv-page-secret-paths-test.js @@ -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` + + `, + { 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` - - `, - { 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` - - `, - { 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` - - `, - { 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` - - `, - { 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(); }); }); diff --git a/ui/tests/integration/components/overview-card-test.js b/ui/tests/integration/components/overview-card-test.js index 38383808cedd..356b018dbc39 100644 --- a/ui/tests/integration/components/overview-card-test.js +++ b/ui/tests/integration/components/overview-card-test.js @@ -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``); assert.dom(SELECTORS.title).hasText('Card title'); }); + test('it returns custom title if both exist', async function (assert) { + await render(hbs` + + <:customTitle> + Fancy custom title + + + `); + 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``); assert.dom(SELECTORS.subtitle).hasText('This is subtext for card'); diff --git a/ui/tests/integration/components/pki/page/pki-overview-test.js b/ui/tests/integration/components/pki/page/pki-overview-test.js index 6043e0181dee..5b15d4943f75 100644 --- a/ui/tests/integration/components/pki/page/pki-overview-test.js +++ b/ui/tests/integration/components/pki/page/pki-overview-test.js @@ -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`,`, { 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`,`, { 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`,`, { 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`,`, { 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`,`, { 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'); }); diff --git a/ui/tests/integration/components/sync/secrets/page/overview-test.js b/ui/tests/integration/components/sync/secrets/page/overview-test.js index 5e587fc3591c..bdc00663bc3b 100644 --- a/ui/tests/integration/components/sync/secrets/page/overview-test.js +++ b/ui/tests/integration/components/sync/secrets/page/overview-test.js @@ -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'); }); }); }); diff --git a/ui/tests/integration/helpers/date-format-test.js b/ui/tests/integration/helpers/date-format-test.js index c5407fa9b2fe..502adb2a2f68 100644 --- a/ui/tests/integration/helpers/date-format-test.js +++ b/ui/tests/integration/helpers/date-format-test.js @@ -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`{{date-format this.timestampDate}}`); + 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'); diff --git a/ui/tests/integration/helpers/date-from-now-test.js b/ui/tests/integration/helpers/date-from-now-test.js index a42392ce7e39..0d64b67661b0 100644 --- a/ui/tests/integration/helpers/date-from-now-test.js +++ b/ui/tests/integration/helpers/date-from-now-test.js @@ -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';