diff --git a/changelog/27455.txt b/changelog/27455.txt new file mode 100644 index 0000000000..38b7c92ae6 --- /dev/null +++ b/changelog/27455.txt @@ -0,0 +1,3 @@ +```release-note:change +ui: Uses the internal/counters/activity/export endpoint for client count export data. +``` \ No newline at end of file diff --git a/ui/app/adapters/clients/activity.js b/ui/app/adapters/clients/activity.js index 419d951fab..7a85c9505d 100644 --- a/ui/app/adapters/clients/activity.js +++ b/ui/app/adapters/clients/activity.js @@ -3,6 +3,7 @@ * SPDX-License-Identifier: BUSL-1.1 */ +import queryParamString from 'vault/utils/query-param-string'; import ApplicationAdapter from '../application'; import { formatDateObject } from 'core/utils/client-count-utils'; import { debug } from '@ember/debug'; @@ -46,6 +47,30 @@ export default class ActivityAdapter extends ApplicationAdapter { }); } + async exportData(query) { + const url = `${this.buildURL()}/internal/counters/activity/export${queryParamString({ + format: query?.format || 'csv', + start_time: query?.start_time ?? undefined, + end_time: query?.end_time ?? undefined, + })}`; + let errorMsg; + try { + const options = query?.namespace ? { namespace: query.namespace } : {}; + const resp = await this.rawRequest(url, 'GET', options); + if (resp.status === 200) { + return resp.blob(); + } + // If it's an empty response (eg 204), there's no data so return an error + errorMsg = 'no data to export in provided time range.'; + } catch (e) { + const { errors } = await e.json(); + errorMsg = errors?.join('. '); + } + if (errorMsg) { + throw new Error(errorMsg); + } + } + urlForFindRecord(id) { // debug reminder so model is stored in Ember data with the same id for consistency if (id !== 'clients/activity') { diff --git a/ui/app/components/clients/attribution.hbs b/ui/app/components/clients/attribution.hbs index b28def4803..311ba5dd9e 100644 --- a/ui/app/components/clients/attribution.hbs +++ b/ui/app/components/clients/attribution.hbs @@ -14,13 +14,44 @@

{{this.chartText.description}}

- {{#if this.hasCsvData}} - + {{#if this.showExportButton}} + + <:alert> + {{#if @upgradesDuringActivity}} + + + Data contains {{pluralize @upgradesDuringActivity.length "upgrade"}}: + + +
    + {{#each @upgradesDuringActivity as |upgrade|}} +
  • + {{upgrade.version}} + {{this.parseAPITimestamp upgrade.timestampInstalled "(MMM d, yyyy)"}} +
  • + {{/each}} +
+
+ + Visit our + + Client count FAQ + + for more information. + +
+ {{/if}} + +
{{/if}}
@@ -79,68 +110,4 @@ {{date-format @responseTimestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}} {{/if}} - - -{{! MODAL FOR CSV DOWNLOAD }} -{{#if this.showCSVDownloadModal}} - - - Export attribution data - - -

- {{this.modalExportText}} -

-

- The - mount_path - for entity/non-entity clients is the corresponding authentication method path - {{if @isSecretsSyncActivated "and for secrets sync clients is the KV v2 engine path"}}. -

-

SELECTED DATE {{if this.formattedEndDate " RANGE"}}

-

- {{this.formattedStartDate}} - {{if this.formattedEndDate "-"}} - {{this.formattedEndDate}}

-
- - - - - - {{#if @upgradesDuringActivity}} - - - Data contains {{pluralize @upgradesDuringActivity.length "upgrade"}}: - - -
    - {{#each @upgradesDuringActivity as |upgrade|}} -
  • - {{upgrade.version}} - {{this.parseAPITimestamp upgrade.timestampInstalled "(MMM d, yyyy)"}} -
  • - {{/each}} -
-
- - Visit our - - Client count FAQ - - for more information. - -
- {{/if}} -
-
-{{/if}} \ No newline at end of file + \ No newline at end of file diff --git a/ui/app/components/clients/attribution.js b/ui/app/components/clients/attribution.js index ed8bde49f3..1f14f3b549 100644 --- a/ui/app/components/clients/attribution.js +++ b/ui/app/components/clients/attribution.js @@ -4,11 +4,12 @@ */ import Component from '@glimmer/component'; -import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; import { service } from '@ember/service'; import { parseAPITimestamp } from 'core/utils/date-formatters'; -import { format, isSameMonth } from 'date-fns'; +import { isSameMonth } from 'date-fns'; +import { sanitizePath } from 'core/utils/sanitize-path'; +import { waitFor } from '@ember/test-waiters'; /** * @module Attribution @@ -17,7 +18,6 @@ import { format, isSameMonth } from 'date-fns'; * * @example * * - * @param {object} totalUsageCounts - object with total client counts for chart tooltip text * @param {object} newUsageCounts - object with new client counts for chart tooltip text * @param {array} totalClientAttribution - array of objects containing a label and breakdown of client counts for total clients * @param {array} newClientAttribution - array of objects containing a label and breakdown of client counts for new clients @@ -44,7 +43,33 @@ import { format, isSameMonth } from 'date-fns'; export default class Attribution extends Component { @service download; - @tracked showCSVDownloadModal = false; + @service store; + @service namespace; + + @tracked canDownload = false; + @tracked showExportModal = false; + @tracked exportFormat = 'csv'; + @tracked downloadError = ''; + + constructor() { + super(...arguments); + this.getExportCapabilities(this.args.selectedNamespace); + } + + @waitFor + async getExportCapabilities(ns = '') { + try { + // selected namespace usually ends in / + const url = ns + ? `${sanitizePath(ns)}/sys/internal/counters/activity/export` + : 'sys/internal/counters/activity/export'; + const cap = await this.store.findRecord('capabilities', url); + this.canDownload = cap.canSudo; + } catch (e) { + // if we can't read capabilities, default to show + this.canDownload = true; + } + } get attributionLegend() { const attributionLegend = [ @@ -59,21 +84,16 @@ export default class Attribution extends Component { return attributionLegend; } - get formattedStartDate() { - if (!this.args.startTimestamp) return null; - return parseAPITimestamp(this.args.startTimestamp, 'MMMM yyyy'); - } - - get formattedEndDate() { - if (!this.args.startTimestamp && !this.args.endTimestamp) return null; - // displays on CSV export modal, no need to display duplicate months and years + get isSingleMonth() { + if (!this.args.startTimestamp && !this.args.endTimestamp) return false; const startDateObject = parseAPITimestamp(this.args.startTimestamp); const endDateObject = parseAPITimestamp(this.args.endTimestamp); - return isSameMonth(startDateObject, endDateObject) ? null : format(endDateObject, 'MMMM yyyy'); + return isSameMonth(startDateObject, endDateObject); } - get hasCsvData() { - return this.args.totalClientAttribution ? this.args.totalClientAttribution.length > 0 : false; + get showExportButton() { + const hasData = this.args.totalClientAttribution ? this.args.totalClientAttribution.length > 0 : false; + return hasData && this.canDownload; } get isSingleNamespace() { @@ -104,7 +124,7 @@ export default class Attribution extends Component { if (!this.args.totalClientAttribution) { return { description: 'There is a problem gathering data' }; } - const dateText = this.formattedEndDate ? 'date range' : 'month'; + const dateText = this.isSingleMonth ? 'month' : 'date range'; switch (this.isSingleNamespace) { case true: return { @@ -129,121 +149,4 @@ export default class Attribution extends Component { return ''; } } - - destructureCountsToArray(object) { - // destructure the namespace object {label: 'some-namespace', entity_clients: 171, non_entity_clients: 20, acme_clients: 6, secret_syncs: 10, clients: 207} - // to get integers for CSV file - const { clients, entity_clients, non_entity_clients, acme_clients, secret_syncs } = object; - const { isSecretsSyncActivated } = this.args; - - return [ - clients, - entity_clients, - non_entity_clients, - acme_clients, - ...(isSecretsSyncActivated ? [secret_syncs] : []), - ]; - } - - constructCsvRow(namespaceColumn, mountColumn = null, totalColumns, newColumns = null) { - // if namespaceColumn is a string, then we're at mount level attribution, otherwise it is an object - // if constructing a namespace row, mountColumn=null so the column is blank, otherwise it is an object - const otherColumns = newColumns ? [...totalColumns, ...newColumns] : [...totalColumns]; - return [ - `${typeof namespaceColumn === 'string' ? namespaceColumn : namespaceColumn.label}`, - `${mountColumn ? mountColumn.label : '*'}`, - ...otherColumns, - ]; - } - - generateCsvData() { - const totalAttribution = this.args.totalClientAttribution; - const newAttribution = this.barChartNewClients ? this.args.newClientAttribution : null; - const { isSecretsSyncActivated } = this.args; - const csvData = []; - // added to clarify that the row of namespace totals without an auth method (blank) are not additional clients - // but indicate the total clients for that ns, including its auth methods - const upgrade = this.args.upgradesDuringActivity?.length - ? `\n **data contains an upgrade (mount summation may not equal namespace totals)` - : ''; - const descriptionOfBlanks = this.isSingleNamespace - ? '' - : `\n *namespace totals, inclusive of mount clients${upgrade}`; - // client type order here should match array order returned by destructureCountsToArray - let csvHeader = [ - 'Namespace path', - `"Mount path${descriptionOfBlanks}"`, // double quotes necessary so description stays inside this cell - 'Total clients', - 'Entity clients', - 'Non-entity clients', - 'ACME clients', - ...(isSecretsSyncActivated ? ['Secrets sync clients'] : []), - ]; - - if (newAttribution) { - csvHeader = [ - ...csvHeader, - 'Total new clients', - 'New entity clients', - 'New non-entity clients', - 'New ACME clients', - ...(isSecretsSyncActivated ? 'New secrets sync clients' : []), - ]; - } - - totalAttribution.forEach((totalClientsObject) => { - const namespace = this.isSingleNamespace ? this.args.selectedNamespace : totalClientsObject; - const mount = this.isSingleNamespace ? totalClientsObject : null; - - // find new client data for namespace/mount object we're iterating over - const newClientsObject = newAttribution - ? newAttribution.find((d) => d.label === totalClientsObject.label) - : null; - - const totalClients = this.destructureCountsToArray(totalClientsObject); - const newClients = newClientsObject ? this.destructureCountsToArray(newClientsObject) : null; - - csvData.push(this.constructCsvRow(namespace, mount, totalClients, newClients)); - // constructCsvRow returns an array that corresponds to a row in the csv file: - // ['ns label', 'mount label', total client #, entity #, non-entity #, acme #, secrets sync #, ...new client #'s] - - // only iterate through mounts if NOT viewing a single namespace - if (!this.isSingleNamespace && namespace.mounts) { - namespace.mounts.forEach((mount) => { - const newMountData = newAttribution - ? newClientsObject?.mounts.find((m) => m.label === mount.label) - : null; - const mountTotalClients = this.destructureCountsToArray(mount); - const mountNewClients = newMountData ? this.destructureCountsToArray(newMountData) : null; - csvData.push(this.constructCsvRow(namespace, mount, mountTotalClients, mountNewClients)); - }); - } - }); - - csvData.unshift(csvHeader); - // make each nested array a comma separated string, join each array "row" in csvData with line break (\n) - return csvData.map((d) => d.join()).join('\n'); - } - - get formattedCsvFileName() { - const endRange = this.formattedEndDate ? `-${this.formattedEndDate}` : ''; - const csvDateRange = this.formattedStartDate ? `_${this.formattedStartDate + endRange}` : ''; - return this.isSingleNamespace - ? `clients_by_mount_path${csvDateRange}` - : `clients_by_namespace${csvDateRange}`; - } - - get modalExportText() { - const { isSecretsSyncActivated } = this.args; - return `This export will include the namespace path, mount path and associated total entity, non-entity${ - isSecretsSyncActivated ? ', ACME and secrets sync clients' : ' and ACME clients' - } for the ${this.formattedEndDate ? 'date range' : 'month'} below.`; - } - - @action - exportChartData(filename) { - const contents = this.generateCsvData(); - this.download.csv(filename, contents); - this.showCSVDownloadModal = false; - } } diff --git a/ui/app/components/clients/export-button.hbs b/ui/app/components/clients/export-button.hbs new file mode 100644 index 0000000000..b947a29654 --- /dev/null +++ b/ui/app/components/clients/export-button.hbs @@ -0,0 +1,72 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + + + +{{! MODAL FOR CSV DOWNLOAD }} +{{#if this.showExportModal}} + + + Export activity data + + + {{#if this.exportChartData.isRunning}} +

+ Your export request is being processed. This may take some time; please do not navigate away from this page. +

+

+ +

+ {{else}} +

+ This file will include an export of the clients that had activity within the date range below. See the + activity export documentation + for more details. +

+

SELECTED DATE {{if this.formattedEndDate " RANGE"}}

+

+ {{this.formattedStartDate}} + {{if this.formattedEndDate "-"}} + {{this.formattedEndDate}}

+ + + Export format + + + + + + {{/if}} + + {{#if this.downloadError}} + + CSV export failed + {{this.downloadError}} + + {{/if}} +
+ + + + + + {{yield to="alert"}} + +
+{{/if}} \ No newline at end of file diff --git a/ui/app/components/clients/export-button.js b/ui/app/components/clients/export-button.js new file mode 100644 index 0000000000..43a3be252f --- /dev/null +++ b/ui/app/components/clients/export-button.js @@ -0,0 +1,93 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { parseAPITimestamp } from 'core/utils/date-formatters'; +import { sanitizePath } from 'core/utils/sanitize-path'; +import { format, isSameMonth } from 'date-fns'; +import { task } from 'ember-concurrency'; + +/** + * @module ClientsExportButtonComponent + * ClientsExportButton components are used to display the export button, manage the modal, and download the file from the clients export API + * + * @example + * ```js + * + * ``` + * @param {string} [startTimestamp] - ISO timestamp of start time, to be passed to export request + * @param {string} [endTimestamp] - ISO timestamp of end time, to be passed to export request + * @param {string} [namespace] - namespace filter. Will be appended to the current namespace in the export request. + */ +export default class ClientsExportButtonComponent extends Component { + @service download; + @service namespace; + @service store; + + @tracked showExportModal = false; + @tracked exportFormat = 'csv'; + @tracked downloadError = ''; + + get formattedStartDate() { + if (!this.args.startTimestamp) return null; + return parseAPITimestamp(this.args.startTimestamp, 'MMMM yyyy'); + } + + get formattedEndDate() { + if (!this.args.startTimestamp && !this.args.endTimestamp) return null; + // displays on CSV export modal, no need to display duplicate months and years + const startDateObject = parseAPITimestamp(this.args.startTimestamp); + const endDateObject = parseAPITimestamp(this.args.endTimestamp); + return isSameMonth(startDateObject, endDateObject) ? null : format(endDateObject, 'MMMM yyyy'); + } + + get formattedCsvFileName() { + const endRange = this.formattedEndDate ? `-${this.formattedEndDate}` : ''; + const csvDateRange = this.formattedStartDate ? `_${this.formattedStartDate + endRange}` : ''; + const ns = this.namespaceFilter ? `_${this.namespaceFilter}` : ''; + return `clients_export${ns}${csvDateRange}`; + } + + get namespaceFilter() { + const currentNs = this.namespace.path; + const { selectedNamespace } = this.args; + return selectedNamespace ? sanitizePath(`${currentNs}/${selectedNamespace}`) : sanitizePath(currentNs); + } + + async getExportData() { + const adapter = this.store.adapterFor('clients/activity'); + const { startTimestamp, endTimestamp } = this.args; + return adapter.exportData({ + // the API only accepts json or csv + format: this.exportFormat === 'jsonl' ? 'json' : 'csv', + start_time: startTimestamp, + end_time: endTimestamp, + namespace: this.namespaceFilter, + }); + } + + exportChartData = task({ drop: true }, async (filename) => { + try { + const contents = await this.getExportData(); + this.download.download(filename, contents, this.exportFormat); + this.showExportModal = false; + } catch (e) { + this.downloadError = e.message; + } + }); + + @action setExportFormat(evt) { + const { value } = evt.target; + this.exportFormat = value; + } + + @action resetModal() { + this.showExportModal = false; + this.downloadError = ''; + } +} diff --git a/ui/app/components/clients/page/overview.hbs b/ui/app/components/clients/page/overview.hbs index e8abfc6abf..c4635c97d3 100644 --- a/ui/app/components/clients/page/overview.hbs +++ b/ui/app/components/clients/page/overview.hbs @@ -16,7 +16,6 @@ {{#if this.hasAttributionData}} -
diff --git a/ui/app/routes/vault/cluster/clients/counts.ts b/ui/app/routes/vault/cluster/clients/counts.ts index 4170d02c4f..c386acc1b7 100644 --- a/ui/app/routes/vault/cluster/clients/counts.ts +++ b/ui/app/routes/vault/cluster/clients/counts.ts @@ -29,7 +29,7 @@ export default class ClientsCountsRoute extends Route { queryParams = { start_time: { refreshModel: true, replace: true }, end_time: { refreshModel: true, replace: true }, - ns: { refreshModel: false, replace: true }, + ns: { refreshModel: true, replace: true }, mountPath: { refreshModel: false, replace: true }, }; diff --git a/ui/app/services/download.ts b/ui/app/services/download.ts index 537069da27..3ca9546c51 100644 --- a/ui/app/services/download.ts +++ b/ui/app/services/download.ts @@ -11,6 +11,7 @@ interface Extensions { hcl: string; sentinel: string; json: string; + jsonl: string; pem: string; txt: string; } @@ -21,6 +22,7 @@ const EXTENSION_TO_MIME: Extensions = { hcl: 'text/plain', sentinel: 'text/plain', json: 'application/json', + jsonl: 'application/json', pem: 'application/x-pem-file', txt: 'text/plain', }; diff --git a/ui/app/utils/query-param-string.js b/ui/app/utils/query-param-string.js new file mode 100644 index 0000000000..838375f550 --- /dev/null +++ b/ui/app/utils/query-param-string.js @@ -0,0 +1,31 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { isEmptyValue } from 'core/helpers/is-empty-value'; + +/** + * queryParamString converts an object to a query param string with URL encoded keys and values. + * It does not include values that are falsey. + * @param {object} queryObject with key-value pairs of desired URL params + * @returns string like ?key=val1&key2=val2 + */ +export default function queryParamString(queryObject) { + if ( + !queryObject || + isEmptyValue(queryObject) || + typeof queryObject !== 'object' || + Array.isArray(queryObject) + ) + return ''; + return Object.keys(queryObject).reduce((prev, key) => { + const value = queryObject[key]; + if (!value) return prev; + const keyval = `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; + if (prev === '?') { + return `${prev}${keyval}`; + } + return `${prev}&${keyval}`; + }, '?'); +} diff --git a/ui/lib/core/addon/helpers/is-empty-value.js b/ui/lib/core/addon/helpers/is-empty-value.js index 513353f692..7af0b6814a 100644 --- a/ui/lib/core/addon/helpers/is-empty-value.js +++ b/ui/lib/core/addon/helpers/is-empty-value.js @@ -5,7 +5,7 @@ import { helper } from '@ember/component/helper'; -export default helper(function isEmptyValue([value], { hasDefault = false }) { +export function isEmptyValue(value, hasDefault = false) { if (hasDefault) { value = hasDefault; } @@ -13,4 +13,8 @@ export default helper(function isEmptyValue([value], { hasDefault = false }) { return Object.keys(value).length === 0; } return value == null || value === ''; +} + +export default helper(function ([value], { hasDefault = false }) { + return isEmptyValue(value, hasDefault); }); diff --git a/ui/tests/acceptance/clients/counts/overview-test.js b/ui/tests/acceptance/clients/counts/overview-test.js index 56d8ce5000..19b4c9a0a2 100644 --- a/ui/tests/acceptance/clients/counts/overview-test.js +++ b/ui/tests/acceptance/clients/counts/overview-test.js @@ -18,6 +18,8 @@ import { clickTrigger } from 'ember-power-select/test-support/helpers'; import { formatNumber } from 'core/helpers/format-number'; import timestamp from 'core/utils/timestamp'; import ss from 'vault/tests/pages/components/search-select'; +import { runCmd, tokenWithPolicyCmd } from 'vault/tests/helpers/commands'; +import { selectChoose } from 'ember-power-select/test-support'; import { format } from 'date-fns'; const searchSelect = create(ss); @@ -146,7 +148,14 @@ module('Acceptance | clients | overview', function (hooks) { test('totals filter correctly with full data', async function (assert) { // stub secrets sync being activated - this.owner.lookup('service:flags').activatedFlags = ['secrets-sync']; + this.server.get('/sys/activation-flags', function () { + return { + data: { + activated: ['secrets-sync'], + unactivated: [], + }, + }; + }); assert .dom(CHARTS.container('Vault client counts')) @@ -221,6 +230,29 @@ module('Acceptance | clients | overview', function (hooks) { .includesText(`${expectedStats[label]}`, `label: ${label} is back to unfiltered value`); } }); + + test('it updates export button visibility as namespace is filtered', async function (assert) { + const ns = 'ns7'; + // create a user that only has export access for specific namespace + const userToken = await runCmd( + tokenWithPolicyCmd( + 'cc-export', + ` + path "${ns}/sys/internal/counters/activity/export" { + capabilities = ["sudo"] + } + ` + ) + ); + await authPage.login(userToken); + await visit('/vault/clients/counts/overview'); + assert.dom(CLIENT_COUNT.exportButton).doesNotExist(); + + // FILTER BY ALLOWED NAMESPACE + await selectChoose('#namespace-search-select', ns); + + assert.dom(CLIENT_COUNT.exportButton).exists(); + }); }); module('Acceptance | clients | overview | sync in license, activated', function (hooks) { diff --git a/ui/tests/acceptance/oidc-provider-test.js b/ui/tests/acceptance/oidc-provider-test.js index f69ae5070f..14b3f30f9f 100644 --- a/ui/tests/acceptance/oidc-provider-test.js +++ b/ui/tests/acceptance/oidc-provider-test.js @@ -15,6 +15,7 @@ import enablePage from 'vault/tests/pages/settings/auth/enable'; import { visit, settled, currentURL, waitFor, currentRouteName } from '@ember/test-helpers'; import { clearRecord } from 'vault/tests/helpers/oidc-config'; import { runCmd } from 'vault/tests/helpers/commands'; +import queryParamString from 'vault/utils/query-param-string'; const authFormComponent = create(authForm); @@ -82,18 +83,13 @@ const getAuthzUrl = (providerName, redirect, clientId, params) => { const queryParams = { client_id: clientId, nonce: 'abc123', - redirect_uri: encodeURIComponent(redirect), + redirect_uri: redirect, response_type: 'code', scope: 'openid', state: 'foobar', ...params, }; - const queryString = Object.keys(queryParams).reduce((prev, key, idx) => { - if (idx === 0) { - return `${prev}${key}=${queryParams[key]}`; - } - return `${prev}&${key}=${queryParams[key]}`; - }, '?'); + const queryString = queryParamString(queryParams); return `/vault/identity/oidc/provider/${providerName}/authorize${queryString}`; }; diff --git a/ui/tests/helpers/clients/client-count-selectors.ts b/ui/tests/helpers/clients/client-count-selectors.ts index ef820a6860..31532d5a60 100644 --- a/ui/tests/helpers/clients/client-count-selectors.ts +++ b/ui/tests/helpers/clients/client-count-selectors.ts @@ -30,6 +30,7 @@ export const CLIENT_COUNT = { selectedAuthMount: 'div#mounts-search-select [data-test-selected-option] div', selectedNs: 'div#namespace-search-select [data-test-selected-option] div', upgradeWarning: '[data-test-clients-upgrade-warning]', + exportButton: '[data-test-attribution-export-button]', }; export const CHARTS = { diff --git a/ui/tests/integration/components/clients/attribution-test.js b/ui/tests/integration/components/clients/attribution-test.js index 863a0feedc..d0d33640c3 100644 --- a/ui/tests/integration/components/clients/attribution-test.js +++ b/ui/tests/integration/components/clients/attribution-test.js @@ -9,14 +9,15 @@ import { setupRenderingTest } from 'ember-qunit'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { endOfMonth, formatRFC3339 } from 'date-fns'; -import { click } from '@ember/test-helpers'; import subMonths from 'date-fns/subMonths'; import timestamp from 'core/utils/timestamp'; -import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { SERIALIZED_ACTIVITY_RESPONSE } from 'vault/tests/helpers/clients/client-count-helpers'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { capabilitiesStub, overrideResponse } from 'vault/tests/helpers/stubs'; module('Integration | Component | clients/attribution', function (hooks) { setupRenderingTest(hooks); + setupMirage(hooks); hooks.before(function () { this.timestampStub = sinon.replace(timestamp, 'now', sinon.fake.returns(new Date('2018-04-03T14:15:30'))); @@ -24,7 +25,6 @@ module('Integration | Component | clients/attribution', function (hooks) { hooks.beforeEach(function () { const { total, by_namespace } = SERIALIZED_ACTIVITY_RESPONSE; - this.csvDownloadStub = sinon.stub(this.owner.lookup('service:download'), 'csv'); const mockNow = this.timestampStub(); this.mockNow = mockNow; this.startTimestamp = formatRFC3339(subMonths(mockNow, 6)); @@ -35,10 +35,6 @@ module('Integration | Component | clients/attribution', function (hooks) { this.namespaceMountsData = by_namespace.find((ns) => ns.label === 'ns1').mounts; }); - hooks.after(function () { - this.csvDownloadStub.restore(); - }); - test('it renders empty state with no data', async function (assert) { await render(hbs` @@ -207,120 +203,10 @@ module('Integration | Component | clients/attribution', function (hooks) { assert.dom('[data-test-attribution-clients]').includesText('auth method').includesText('8,394'); }); - test('it renders modal', async function (assert) { - await render(hbs` - - `); - await click('[data-test-attribution-export-button]'); - assert - .dom('[data-test-export-modal-title]') - .hasText('Export attribution data', 'modal appears to export csv'); - assert.dom('[ data-test-export-date-range]').includesText('June 2022 - December 2022'); - }); - - test('it downloads csv data for date range', async function (assert) { - assert.expect(2); - - await render(hbs` - - `); - await click('[data-test-attribution-export-button]'); - await click(GENERAL.confirmButton); - const [filename, content] = this.csvDownloadStub.lastCall.args; - assert.strictEqual(filename, 'clients_by_namespace_June 2022-December 2022', 'csv has expected filename'); - assert.strictEqual( - content, - `Namespace path,"Mount path - *namespace totals, inclusive of mount clients",Total clients,Entity clients,Non-entity clients,ACME clients,Secrets sync clients -ns1,*,18903,4256,4138,5699,4810 -ns1,auth/authid/0,8394,4256,4138,0,0 -ns1,kvv2-engine-0,4810,0,0,0,4810 -ns1,pki-engine-0,5699,0,0,5699,0 -root,*,16384,4002,4089,4003,4290 -root,auth/authid/0,8091,4002,4089,0,0 -root,kvv2-engine-0,4290,0,0,0,4290 -root,pki-engine-0,4003,0,0,4003,0`, - 'csv has expected content' + test('it shows the export button if user does has SUDO capabilities', async function (assert) { + this.server.post('/sys/capabilities-self', () => + capabilitiesStub('sys/internal/counters/activity/export', ['sudo']) ); - }); - - test('it downloads csv data for a single month', async function (assert) { - assert.expect(2); - await render(hbs` - - `); - await click('[data-test-attribution-export-button]'); - await click(GENERAL.confirmButton); - const [filename, content] = this.csvDownloadStub.lastCall.args; - assert.strictEqual(filename, 'clients_by_namespace_June 2022', 'csv has single month in filename'); - assert.strictEqual( - content, - `Namespace path,"Mount path - *namespace totals, inclusive of mount clients",Total clients,Entity clients,Non-entity clients,ACME clients,Secrets sync clients -ns1,*,18903,4256,4138,5699,4810 -ns1,auth/authid/0,8394,4256,4138,0,0 -ns1,kvv2-engine-0,4810,0,0,0,4810 -ns1,pki-engine-0,5699,0,0,5699,0 -root,*,16384,4002,4089,4003,4290 -root,auth/authid/0,8091,4002,4089,0,0 -root,kvv2-engine-0,4290,0,0,0,4290 -root,pki-engine-0,4003,0,0,4003,0`, - 'csv has expected content' - ); - }); - - test('it downloads csv data when a namespace is selected', async function (assert) { - assert.expect(2); - this.selectedNamespace = 'ns1'; - - await render(hbs` - - `); - - await click('[data-test-attribution-export-button]'); - await click(GENERAL.confirmButton); - const [filename, content] = this.csvDownloadStub.lastCall.args; - assert.strictEqual( - filename, - 'clients_by_mount_path_June 2022-December 2022', - 'csv has expected filename for a selected namespace' - ); - assert.strictEqual( - content, - `Namespace path,"Mount path",Total clients,Entity clients,Non-entity clients,ACME clients,Secrets sync clients -ns1,auth/authid/0,8394,4256,4138,0,0 -ns1,kvv2-engine-0,4810,0,0,0,4810 -ns1,pki-engine-0,5699,0,0,5699,0`, - 'csv has expected content for a selected namespace' - ); - }); - - test('csv filename omits date if no start/end timestamp', async function (assert) { - assert.expect(1); await render(hbs` `); - - await click('[data-test-attribution-export-button]'); - await click(GENERAL.confirmButton); - const [filename, ,] = this.csvDownloadStub.lastCall.args; - assert.strictEqual(filename, 'clients_by_namespace'); + assert.dom('[data-test-attribution-export-button]').exists(); }); - test('csv filename omits sync clients if not activated', async function (assert) { - assert.expect(1); - this.totalClientAttribution = this.totalClientAttribution.map((ns) => { - const namespace = { ...ns }; - delete namespace.secret_syncs; - return namespace; - }); + test('it hides the export button if user does not have SUDO capabilities', async function (assert) { + this.server.post('/sys/capabilities-self', () => + capabilitiesStub('sys/internal/counters/activity/export', ['read']) + ); + await render(hbs` `); - - await click('[data-test-attribution-export-button]'); - await click(GENERAL.confirmButton); - const [, content] = this.csvDownloadStub.lastCall.args; - assert.strictEqual( - content, - `Namespace path,"Mount path - *namespace totals, inclusive of mount clients",Total clients,Entity clients,Non-entity clients,ACME clients -ns1,*,18903,4256,4138,5699 -ns1,auth/authid/0,8394,4256,4138,0 -ns1,kvv2-engine-0,4810,0,0,0 -ns1,pki-engine-0,5699,0,0,5699 -root,*,16384,4002,4089,4003 -root,auth/authid/0,8091,4002,4089,0 -root,kvv2-engine-0,4290,0,0,0 -root,pki-engine-0,4003,0,0,4003` - ); + assert.dom('[data-test-attribution-export-button]').doesNotExist(); }); - test('csv filename includes upgrade mention if there is upgrade activity', async function (assert) { - assert.expect(1); - this.totalClientAttribution = this.totalClientAttribution.map((ns) => { - const namespace = { ...ns }; - delete namespace.secret_syncs; - return namespace; - }); - this.upgradeActivity = [ - { - previousVersion: '1.9.0', - timestampInstalled: '2023-08-02T00:00:00.000Z', - version: '1.9.1', - }, - ]; + test('defaults to show the export button if capabilities cannot be read', async function (assert) { + this.server.post('/sys/capabilities-self', () => overrideResponse(403)); + await render(hbs` - `); - - await click('[data-test-attribution-export-button]'); - await click(GENERAL.confirmButton); - const [, content] = this.csvDownloadStub.lastCall.args; - assert.strictEqual( - content, - `Namespace path,"Mount path - *namespace totals, inclusive of mount clients - **data contains an upgrade (mount summation may not equal namespace totals)",Total clients,Entity clients,Non-entity clients,ACME clients -ns1,*,18903,4256,4138,5699 -ns1,auth/authid/0,8394,4256,4138,0 -ns1,kvv2-engine-0,4810,0,0,0 -ns1,pki-engine-0,5699,0,0,5699 -root,*,16384,4002,4089,4003 -root,auth/authid/0,8091,4002,4089,0 -root,kvv2-engine-0,4290,0,0,0 -root,pki-engine-0,4003,0,0,4003` - ); + assert.dom('[data-test-attribution-export-button]').exists(); }); }); diff --git a/ui/tests/integration/components/clients/export-button-test.js b/ui/tests/integration/components/clients/export-button-test.js new file mode 100644 index 0000000000..1960fd4354 --- /dev/null +++ b/ui/tests/integration/components/clients/export-button-test.js @@ -0,0 +1,294 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import Sinon from 'sinon'; +import { Response } from 'miragejs'; +import { click, fillIn, render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupMirage } from 'ember-cli-mirage/test-support'; + +import { setupRenderingTest } from 'vault/tests/helpers'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import { overrideResponse } from 'vault/tests/helpers/stubs'; + +module('Integration | Component | clients/export-button', function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.downloadStub = Sinon.stub(this.owner.lookup('service:download'), 'download'); + this.startTimestamp = '2022-06-01T23:00:11.050Z'; + this.endTimestamp = '2022-12-01T23:00:11.050Z'; + this.selectedNamespace = undefined; + + this.renderComponent = async () => { + return render(hbs` + `); + }; + }); + + test('it renders modal with yielded alert', async function (assert) { + await render(hbs` + + <:alert> + + Yielded alert! + + + + `); + + await click('[data-test-attribution-export-button]'); + assert.dom('[data-test-custom-alert]').hasText('Yielded alert!'); + }); + + test('shows the API error on the modal', async function (assert) { + this.server.get('/sys/internal/counters/activity/export', function () { + return new Response( + 403, + { 'Content-Type': 'application/json' }, + { errors: ['this is an error from the API'] } + ); + }); + + await this.renderComponent(); + + await click('[data-test-attribution-export-button]'); + await click(GENERAL.confirmButton); + assert.dom('[data-test-export-error]').hasText('this is an error from the API'); + }); + + test('it works for json format', async function (assert) { + assert.expect(2); + this.server.get('/sys/internal/counters/activity/export', function (_, req) { + assert.deepEqual(req.queryParams, { + format: 'json', + start_time: '2022-06-01T23:00:11.050Z', + end_time: '2022-12-01T23:00:11.050Z', + }); + return new Response(200, { 'Content-Type': 'application/json' }, { example: 'data' }); + }); + + await this.renderComponent(); + + await click('[data-test-attribution-export-button]'); + await fillIn('[data-test-download-format]', 'jsonl'); + await click(GENERAL.confirmButton); + const extension = this.downloadStub.lastCall.args[2]; + assert.strictEqual(extension, 'jsonl'); + }); + + test('it works for csv format', async function (assert) { + assert.expect(2); + + this.server.get('/sys/internal/counters/activity/export', function (_, req) { + assert.deepEqual(req.queryParams, { + format: 'csv', + start_time: '2022-06-01T23:00:11.050Z', + end_time: '2022-12-01T23:00:11.050Z', + }); + return new Response(200, { 'Content-Type': 'text/csv' }, 'example,data'); + }); + + await this.renderComponent(); + + await click('[data-test-attribution-export-button]'); + await fillIn('[data-test-download-format]', 'csv'); + await click(GENERAL.confirmButton); + const extension = this.downloadStub.lastCall.args[2]; + assert.strictEqual(extension, 'csv'); + }); + + test('it sends the current namespace in export request', async function (assert) { + assert.expect(2); + const namespaceSvc = this.owner.lookup('service:namespace'); + namespaceSvc.path = 'foo'; + this.server.get('/sys/internal/counters/activity/export', function (_, req) { + assert.deepEqual(req.requestHeaders, { + 'X-Vault-Namespace': 'foo', + }); + return new Response(200, { 'Content-Type': 'text/csv' }, ''); + }); + + await render(hbs` + + `); + assert.dom('[data-test-attribution-export-button]').exists(); + await click('[data-test-attribution-export-button]'); + await click(GENERAL.confirmButton); + }); + test('it sends the selected namespace in export request', async function (assert) { + assert.expect(2); + this.server.get('/sys/internal/counters/activity/export', function (_, req) { + assert.deepEqual(req.requestHeaders, { + 'X-Vault-Namespace': 'foobar', + }); + return new Response(200, { 'Content-Type': 'text/csv' }, ''); + }); + this.selectedNamespace = 'foobar/'; + + await render(hbs` + + `); + assert.dom('[data-test-attribution-export-button]').exists(); + await click('[data-test-attribution-export-button]'); + await click(GENERAL.confirmButton); + }); + + test('it sends the current + selected namespace in export request', async function (assert) { + assert.expect(2); + const namespaceSvc = this.owner.lookup('service:namespace'); + namespaceSvc.path = 'foo'; + this.server.get('/sys/internal/counters/activity/export', function (_, req) { + assert.deepEqual(req.requestHeaders, { + 'X-Vault-Namespace': 'foo/bar', + }); + return new Response(200, { 'Content-Type': 'text/csv' }, ''); + }); + this.selectedNamespace = 'bar/'; + + await render(hbs` + + `); + assert.dom('[data-test-attribution-export-button]').exists(); + await click('[data-test-attribution-export-button]'); + await click(GENERAL.confirmButton); + }); + + test('it shows a no data message if endpoint returns 204', async function (assert) { + this.server.get('/sys/internal/counters/activity/export', () => overrideResponse(204)); + + await render(hbs` + + `); + await click('[data-test-attribution-export-button]'); + await click(GENERAL.confirmButton); + assert.dom('[data-test-export-error]').hasText('no data to export in provided time range.'); + }); + + module('download naming', function () { + test('is correct for date range', async function (assert) { + assert.expect(2); + this.server.get('/sys/internal/counters/activity/export', function (_, req) { + assert.deepEqual(req.queryParams, { + format: 'csv', + start_time: '2022-06-01T23:00:11.050Z', + end_time: '2022-12-01T23:00:11.050Z', + }); + return new Response(200, { 'Content-Type': 'text/csv' }, ''); + }); + + await this.renderComponent(); + await click('[data-test-attribution-export-button]'); + await click(GENERAL.confirmButton); + const args = this.downloadStub.lastCall.args; + const [filename] = args; + assert.strictEqual(filename, 'clients_export_June 2022-December 2022', 'csv has expected filename'); + }); + + test('is correct for a single month', async function (assert) { + assert.expect(2); + this.endTimestamp = '2022-06-21T23:00:11.050Z'; + this.server.get('/sys/internal/counters/activity/export', function (_, req) { + assert.deepEqual(req.queryParams, { + format: 'csv', + start_time: '2022-06-01T23:00:11.050Z', + end_time: '2022-06-21T23:00:11.050Z', + }); + return new Response(200, { 'Content-Type': 'text/csv' }, ''); + }); + await this.renderComponent(); + + await click('[data-test-attribution-export-button]'); + await click(GENERAL.confirmButton); + const [filename] = this.downloadStub.lastCall.args; + assert.strictEqual(filename, 'clients_export_June 2022', 'csv has single month in filename'); + }); + test('omits date if no start/end timestamp', async function (assert) { + assert.expect(2); + this.startTimestamp = undefined; + this.endTimestamp = undefined; + + this.server.get('/sys/internal/counters/activity/export', function (_, req) { + assert.deepEqual(req.queryParams, { + format: 'csv', + }); + return new Response(200, { 'Content-Type': 'text/csv' }, ''); + }); + + await this.renderComponent(); + + await click('[data-test-attribution-export-button]'); + await click(GENERAL.confirmButton); + const [filename] = this.downloadStub.lastCall.args; + assert.strictEqual(filename, 'clients_export'); + }); + + test('includes current namespace', async function (assert) { + assert.expect(2); + this.startTimestamp = undefined; + this.endTimestamp = undefined; + const namespace = this.owner.lookup('service:namespace'); + namespace.path = 'bar/'; + + this.server.get('/sys/internal/counters/activity/export', function (_, req) { + assert.deepEqual(req.queryParams, { + format: 'csv', + }); + return new Response(200, { 'Content-Type': 'text/csv' }, ''); + }); + + await this.renderComponent(); + + await click('[data-test-attribution-export-button]'); + await click(GENERAL.confirmButton); + const [filename] = this.downloadStub.lastCall.args; + assert.strictEqual(filename, 'clients_export_bar'); + }); + + test('includes selectedNamespace', async function (assert) { + assert.expect(2); + this.startTimestamp = undefined; + this.endTimestamp = undefined; + this.selectedNamespace = 'foo/'; + + this.server.get('/sys/internal/counters/activity/export', function (_, req) { + assert.deepEqual(req.queryParams, { + format: 'csv', + }); + return new Response(200, { 'Content-Type': 'text/csv' }, ''); + }); + + await this.renderComponent(); + + await click('[data-test-attribution-export-button]'); + await click(GENERAL.confirmButton); + const [filename] = this.downloadStub.lastCall.args; + assert.strictEqual(filename, 'clients_export_foo'); + }); + }); +}); diff --git a/ui/tests/unit/adapters/clients-activity-test.js b/ui/tests/unit/adapters/clients-activity-test.js index 0e732c7933..9eadbfcac3 100644 --- a/ui/tests/unit/adapters/clients-activity-test.js +++ b/ui/tests/unit/adapters/clients-activity-test.js @@ -15,14 +15,13 @@ module('Unit | Adapter | clients activity', function (hooks) { setupTest(hooks); setupMirage(hooks); - hooks.before(function () { - this.timestampStub = sinon.replace(timestamp, 'now', sinon.fake.returns(new Date('2023-01-13T09:30:15'))); - }); hooks.beforeEach(function () { + this.timestampStub = sinon.replace(timestamp, 'now', sinon.fake.returns(new Date('2023-01-13T09:30:15'))); this.store = this.owner.lookup('service:store'); this.modelName = 'clients/activity'; - this.startDate = subMonths(this.timestampStub(), 6); - this.endDate = this.timestampStub(); + const mockNow = timestamp.now(); + this.startDate = subMonths(mockNow, 6); + this.endDate = mockNow; this.readableUnix = (unix) => parseAPITimestamp(fromUnixTime(unix).toISOString(), 'MMMM dd yyyy'); }); @@ -166,4 +165,49 @@ module('Unit | Adapter | clients activity', function (hooks) { this.store.queryRecord(this.modelName, {}); }); + + module('exportData', function (hooks) { + hooks.beforeEach(function () { + this.adapter = this.store.adapterFor('clients/activity'); + }); + test('it requests with correct params when no query', async function (assert) { + assert.expect(1); + + this.server.get('sys/internal/counters/activity/export', (schema, req) => { + assert.propEqual(req.queryParams, { format: 'csv' }); + }); + + await this.adapter.exportData(); + }); + + test('it requests with correct params when start only', async function (assert) { + assert.expect(1); + + this.server.get('sys/internal/counters/activity/export', (schema, req) => { + assert.propEqual(req.queryParams, { format: 'csv', start_time: '2024-04-01T00:00:00.000Z' }); + }); + + await this.adapter.exportData({ start_time: '2024-04-01T00:00:00.000Z' }); + }); + + test('it requests with correct params when all params', async function (assert) { + assert.expect(2); + + this.server.get('sys/internal/counters/activity/export', (schema, req) => { + assert.propEqual(req.requestHeaders, { 'X-Vault-Namespace': 'foo/bar' }); + assert.propEqual(req.queryParams, { + format: 'json', + start_time: '2024-04-01T00:00:00.000Z', + end_time: '2024-05-31T00:00:00.000Z', + }); + }); + + await this.adapter.exportData({ + start_time: '2024-04-01T00:00:00.000Z', + end_time: '2024-05-31T00:00:00.000Z', + format: 'json', + namespace: 'foo/bar', + }); + }); + }); }); diff --git a/ui/tests/unit/utils/query-param-string-test.js b/ui/tests/unit/utils/query-param-string-test.js new file mode 100644 index 0000000000..57d95e3071 --- /dev/null +++ b/ui/tests/unit/utils/query-param-string-test.js @@ -0,0 +1,42 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import queryParamString from 'vault/utils/query-param-string'; +import { module, test } from 'qunit'; + +module('Unit | Utility | query-param-string', function () { + [ + { + scenario: 'object with nonencoded keys and values', + obj: { redirect: 'https://hashicorp.com', some$key: 'normal-value', number: 7 }, + expected: '?redirect=https%3A%2F%2Fhashicorp.com&some%24key=normal-value&number=7', + }, + { + scenario: 'object with falsey values', + obj: { redirect: '', null: null, foo: 'bar', number: 0 }, + expected: '?foo=bar', + }, + { + scenario: 'empty object', + obj: {}, + expected: '', + }, + { + scenario: 'array', + obj: ['some', 'array'], + expected: '', + }, + { + scenario: 'string', + obj: 'foobar', + expected: '', + }, + ].forEach((testCase) => { + test(`it works when ${testCase.scenario}`, function (assert) { + const result = queryParamString(testCase.obj); + assert.strictEqual(result, testCase.expected); + }); + }); +});