UI: Use Client Count export API (#27455)

This commit is contained in:
Chelsea Shaw
2024-08-01 11:03:31 -05:00
committed by GitHub
parent c23ebb173f
commit 10068ffb0a
19 changed files with 748 additions and 408 deletions

3
changelog/27455.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:change
ui: Uses the internal/counters/activity/export endpoint for client count export data.
```

View File

@@ -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') {

View File

@@ -14,13 +14,44 @@
<p class="chart-description" data-test-attribution-description>{{this.chartText.description}}</p>
</div>
<div class="header-right">
{{#if this.hasCsvData}}
<Hds::Button
data-test-attribution-export-button
@text="Export attribution data"
@color="secondary"
{{on "click" (fn (mut this.showCSVDownloadModal) true)}}
/>
{{#if this.showExportButton}}
<Clients::ExportButton
@startTimestamp={{@startTimestamp}}
@endTimestamp={{@endTimestamp}}
@selectedNamespace={{@selectedNamespace}}
>
<:alert>
{{#if @upgradesDuringActivity}}
<Hds::Alert class="has-top-padding-m" @type="compact" @color="warning" as |A|>
<A.Description>
<strong>Data contains {{pluralize @upgradesDuringActivity.length "upgrade"}}:</strong>
</A.Description>
<A.Description>
<ul class="bullet">
{{#each @upgradesDuringActivity as |upgrade|}}
<li>
{{upgrade.version}}
{{this.parseAPITimestamp upgrade.timestampInstalled "(MMM d, yyyy)"}}
</li>
{{/each}}
</ul>
</A.Description>
<A.Description>
Visit our
<Hds::Link::Inline
@isHrefExternal={{true}}
@href={{doc-link
"/vault/docs/concepts/client-count/faq#q-which-vault-version-reflects-the-most-accurate-client-counts"
}}
>
Client count FAQ
</Hds::Link::Inline>
for more information.
</A.Description>
</Hds::Alert>
{{/if}}
</:alert>
</Clients::ExportButton>
{{/if}}
</div>
</div>
@@ -79,68 +110,4 @@
{{date-format @responseTimestamp "MMM d yyyy, h:mm:ss aaa" withTimeZone=true}}
{{/if}}
</div>
</div>
{{! MODAL FOR CSV DOWNLOAD }}
{{#if this.showCSVDownloadModal}}
<Hds::Modal id="attribution-csv-download-modal" @onClose={{fn (mut this.showCSVDownloadModal) false}} as |M|>
<M.Header @icon="info" data-test-export-modal-title>
Export attribution data
</M.Header>
<M.Body>
<p class="has-bottom-margin-s">
{{this.modalExportText}}
</p>
<p class="has-bottom-margin-s">
The
<code>mount_path</code>
for entity/non-entity clients is the corresponding authentication method path
{{if @isSecretsSyncActivated "and for secrets sync clients is the KV v2 engine path"}}.
</p>
<p class="has-bottom-margin-s is-subtitle-gray">SELECTED DATE {{if this.formattedEndDate " RANGE"}}</p>
<p class="has-bottom-margin-s" data-test-export-date-range>
{{this.formattedStartDate}}
{{if this.formattedEndDate "-"}}
{{this.formattedEndDate}}</p>
</M.Body>
<M.Footer as |F|>
<Hds::ButtonSet>
<Hds::Button
@text="Export"
{{on "click" (fn this.exportChartData this.formattedCsvFileName)}}
data-test-confirm-button
/>
<Hds::Button @text="Cancel" @color="secondary" {{on "click" F.close}} />
</Hds::ButtonSet>
{{#if @upgradesDuringActivity}}
<Hds::Alert class="has-top-padding-m" @type="compact" @color="warning" as |A|>
<A.Description>
<strong>Data contains {{pluralize @upgradesDuringActivity.length "upgrade"}}:</strong>
</A.Description>
<A.Description>
<ul class="bullet">
{{#each @upgradesDuringActivity as |upgrade|}}
<li>
{{upgrade.version}}
{{this.parseAPITimestamp upgrade.timestampInstalled "(MMM d, yyyy)"}}
</li>
{{/each}}
</ul>
</A.Description>
<A.Description>
Visit our
<Hds::Link::Inline
@isHrefExternal={{true}}
@href={{doc-link
"/vault/docs/concepts/client-count/faq#q-which-vault-version-reflects-the-most-accurate-client-counts"
}}
>
Client count FAQ
</Hds::Link::Inline>
for more information.
</A.Description>
</Hds::Alert>
{{/if}}
</M.Footer>
</Hds::Modal>
{{/if}}
</div>

View File

@@ -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
* <Clients::Attribution
* @totalUsageCounts={{this.totalUsageCounts}}
* @newUsageCounts={{this.newUsageCounts}}
* @totalClientAttribution={{this.totalClientAttribution}}
* @newClientAttribution={{this.newClientAttribution}}
@@ -29,7 +29,6 @@ import { format, isSameMonth } from 'date-fns';
* @upgradesDuringActivity={{array (hash version="1.10.1" previousVersion="1.9.1" timestampInstalled= "2021-11-18T10:23:16Z") }}
* />
*
* @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;
}
}

View File

@@ -0,0 +1,72 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Hds::Button
data-test-attribution-export-button
@text="Export activity data"
@color="secondary"
{{on "click" (fn (mut this.showExportModal) true)}}
/>
{{! MODAL FOR CSV DOWNLOAD }}
{{#if this.showExportModal}}
<Hds::Modal id="attribution-csv-download-modal" class="has-text-left" @onClose={{this.resetModal}} as |M|>
<M.Header @icon="info" data-test-export-modal-title>
Export activity data
</M.Header>
<M.Body>
{{#if this.exportChartData.isRunning}}
<p class="has-bottom-margin-s">
Your export request is being processed. This may take some time; please do not navigate away from this page.
</p>
<p class="has-bottom-margin-s has-text-centered">
<Icon @name="loading" @size="24" />
</p>
{{else}}
<p class="has-bottom-margin-s">
This file will include an export of the clients that had activity within the date range below. See the
<DocLink @path="/vault/api-docs/system/internal-counters#activity-export">activity export documentation</DocLink>
for more details.
</p>
<p class="has-bottom-margin-s is-subtitle-gray">SELECTED DATE {{if this.formattedEndDate " RANGE"}}</p>
<p class="has-bottom-margin-s" data-test-export-date-range>
{{this.formattedStartDate}}
{{if this.formattedEndDate "-"}}
{{this.formattedEndDate}}</p>
<Hds::Form::Select::Field
class="has-bottom-margin-s"
{{on "change" this.setExportFormat}}
data-test-download-format
as |F|
>
<F.Label>Export format</F.Label>
<F.Options>
<option value="csv" selected={{eq this.exportFormat "csv"}}>CSV</option>
<option value="jsonl" selected={{eq this.exportFormat "jsonl"}}>JSON Lines</option>
</F.Options>
</Hds::Form::Select::Field>
{{/if}}
{{#if this.downloadError}}
<Hds::Alert @type="inline" @color="critical" as |A|>
<A.Title>CSV export failed</A.Title>
<A.Description data-test-export-error>{{this.downloadError}}</A.Description>
</Hds::Alert>
{{/if}}
</M.Body>
<M.Footer as |F|>
<Hds::ButtonSet>
<Hds::Button
@text="Export"
{{on "click" (perform this.exportChartData this.formattedCsvFileName)}}
data-test-confirm-button
/>
<Hds::Button @text="Cancel" @color="secondary" {{on "click" F.close}} />
</Hds::ButtonSet>
{{yield to="alert"}}
</M.Footer>
</Hds::Modal>
{{/if}}

View File

@@ -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
* <Clients::ExportButton @startTimestamp="2022-06-01T23:00:11.050Z" @endTimestamp="2022-12-01T23:00:11.050Z" @selectedNamespace="foo" />
* ```
* @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 = '';
}
}

View File

@@ -16,7 +16,6 @@
{{#if this.hasAttributionData}}
<Clients::Attribution
@isSecretsSyncActivated={{this.flags.secretsSyncIsActivated}}
@totalUsageCounts={{this.totalUsageCounts}}
@newUsageCounts={{this.newClientCounts}}
@totalClientAttribution={{this.totalClientAttribution}}
@newClientAttribution={{this.newClientAttribution}}

View File

@@ -25,7 +25,6 @@
<div class="is-flex-row">
<StatText @label="Entity" @value={{@runningTotals.entity_clients}} @size="m" />
<StatText @label="Non-entity" @value={{@runningTotals.non_entity_clients}} @size="m" class="has-left-margin-l" />
</div>
</div>
<div class="data-details-bottom is-flex-row">

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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 = {

View File

@@ -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`
<Clients::Attribution />
@@ -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`
<Clients::Attribution
@totalClientAttribution={{this.namespaceMountsData}}
@responseTimestamp={{this.timestamp}}
@startTimestamp="2022-06-01T23:00:11.050Z"
@endTimestamp="2022-12-01T23:00:11.050Z"
/>
`);
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`
<Clients::Attribution
@isSecretsSyncActivated={{true}}
@totalClientAttribution={{this.totalClientAttribution}}
@responseTimestamp={{this.timestamp}}
@startTimestamp="2022-06-01T23:00:11.050Z"
@endTimestamp="2022-12-01T23:00:11.050Z"
/>
`);
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`
<Clients::Attribution
@isSecretsSyncActivated={{true}}
@totalClientAttribution={{this.totalClientAttribution}}
@responseTimestamp={{this.timestamp}}
@startTimestamp="2022-06-01T23:00:11.050Z"
@endTimestamp="2022-06-21T23:00:11.050Z"
/>
`);
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`
<Clients::Attribution
@isSecretsSyncActivated={{true}}
@totalClientAttribution={{this.namespaceMountsData}}
@selectedNamespace={{this.selectedNamespace}}
@responseTimestamp={{this.timestamp}}
@startTimestamp="2022-06-01T23:00:11.050Z"
@endTimestamp="2022-12-21T23:00:11.050Z"
/>
`);
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`
<Clients::Attribution
@@ -328,85 +214,32 @@ ns1,pki-engine-0,5699,0,0,5699,0`,
@responseTimestamp={{this.timestamp}}
/>
`);
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`
<Clients::Attribution
@isSecretsSyncActivated={{false}}
@totalClientAttribution={{this.totalClientAttribution}}
@responseTimestamp={{this.timestamp}}
/>
`);
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`
<Clients::Attribution
@isSecretsSyncActivated={{false}}
<Clients::ExportButton
@totalClientAttribution={{this.totalClientAttribution}}
@responseTimestamp={{this.timestamp}}
@upgradesDuringActivity={{this.upgradeActivity}}
/>
`);
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();
});
});

View File

@@ -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`
<Clients::ExportButton
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
@selectedNamespace={{this.selectedNamespace}}
/>`);
};
});
test('it renders modal with yielded alert', async function (assert) {
await render(hbs`
<Clients::ExportButton
@startTimestamp={{this.startTimestamp}}
@endTimestamp={{this.endTimestamp}}
>
<:alert>
<Hds::Alert class="has-top-padding-m" @type="compact" @color="warning" as |A|>
<A.Description data-test-custom-alert>Yielded alert!</A.Description>
</Hds::Alert>
</:alert>
</Clients::ExportButton>
`);
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`
<Clients::ExportButton
@totalClientAttribution={{this.totalClientAttribution}}
@responseTimestamp={{this.timestamp}}
/>
`);
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`
<Clients::ExportButton
@totalClientAttribution={{this.totalClientAttribution}}
@responseTimestamp={{this.timestamp}}
@selectedNamespace={{this.selectedNamespace}}
/>
`);
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`
<Clients::ExportButton
@totalClientAttribution={{this.totalClientAttribution}}
@responseTimestamp={{this.timestamp}}
@selectedNamespace={{this.selectedNamespace}}
/>
`);
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`
<Clients::ExportButton
@totalClientAttribution={{this.totalClientAttribution}}
@responseTimestamp={{this.timestamp}}
/>
`);
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');
});
});
});

View File

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

View File

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