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