diff --git a/changelog/_8880.txt b/changelog/_8880.txt new file mode 100644 index 0000000000..de06f46e3c --- /dev/null +++ b/changelog/_8880.txt @@ -0,0 +1,3 @@ +```release-note:feature +**UI Client List Explorer (Enterprise)**: Adds ability to view and filter client IDs and metadata by namespace, mount path, or mount type for a billing period. +``` diff --git a/ui/app/components/clients/activity.ts b/ui/app/components/clients/activity.ts index d5aad3a5dc..cc262d9a66 100644 --- a/ui/app/components/clients/activity.ts +++ b/ui/app/components/clients/activity.ts @@ -10,13 +10,13 @@ import Component from '@glimmer/component'; import { action } from '@ember/object'; import type ClientsActivityModel from 'vault/models/clients/activity'; -import type { ActivityExportData, ClientFilterTypes, EntityClients } from 'core/utils/client-count-utils'; +import type { ActivityExportData, ClientFilterTypes } from 'core/utils/client-count-utils'; /* This component does not actually render and is the base class to house shared computations between the Clients::Page::Overview and Clients::Page::List components */ -interface Args { +export interface Args { activity: ClientsActivityModel; - exportData: ActivityExportData[] | EntityClients[]; + exportData: ActivityExportData[]; onFilterChange: CallableFunction; filterQueryParams: Record; } diff --git a/ui/app/components/clients/counts-card.hbs b/ui/app/components/clients/counts-card.hbs index 776340bdc0..b9d8917660 100644 --- a/ui/app/components/clients/counts-card.hbs +++ b/ui/app/components/clients/counts-card.hbs @@ -9,7 +9,9 @@ Data visualizations render in in a flex row with a 1/3-width left element and a
- {{@title}} + {{#if @title}} + {{@title}} + {{/if}} {{#if @description}} {{@description}} {{/if}} diff --git a/ui/app/components/clients/counts/nav-bar.hbs b/ui/app/components/clients/counts/nav-bar.hbs index b88e59cc0b..ad4e67e2c3 100644 --- a/ui/app/components/clients/counts/nav-bar.hbs +++ b/ui/app/components/clients/counts/nav-bar.hbs @@ -11,11 +11,9 @@
  • - {{#if this.isNotProduction}} - - Client list - - {{/if}} + + Client list +
  • \ No newline at end of file diff --git a/ui/app/components/clients/counts/nav-bar.ts b/ui/app/components/clients/counts/nav-bar.ts deleted file mode 100644 index 63af623e19..0000000000 --- a/ui/app/components/clients/counts/nav-bar.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: BUSL-1.1 - */ - -// TODO this component just exists while the client-list tab is in development for the 1.21 release -// Unless more is added to it, it can be removed when the `client list` tab+route is unhidden -import Component from '@glimmer/component'; -import config from 'vault/config/environment'; - -export default class NavBar extends Component { - get isNotProduction() { - return config.environment !== 'production'; - } -} diff --git a/ui/app/components/clients/filter-toolbar.hbs b/ui/app/components/clients/filter-toolbar.hbs index 8e294f4f51..514d58bd55 100644 --- a/ui/app/components/clients/filter-toolbar.hbs +++ b/ui/app/components/clients/filter-toolbar.hbs @@ -3,60 +3,66 @@ SPDX-License-Identifier: BUSL-1.1 }} - - {{#each-in this.dropdownConfig as |filterProperty d|}} - {{#let d.label d.dropdownItems d.searchProperty as |label dropdownItems searchProperty|}} - - - - - - - {{#let (this.searchDropdown dropdownItems searchProperty) as |matchingItems|}} - {{#each matchingItems as |item|}} - - {{item}} - - {{else}} - + + {{! "filterProperty" is the tracked variable in the component class that corresponds to the filter type }} + {{#each-in this.dropdownConfig as |filterProperty d|}} + {{! "searchProperty" is the tracked variable in the component class that corresponds to the search input }} + {{#let d.label d.dropdownItems d.searchProperty as |label dropdownItems searchProperty|}} + + + + - {{/each}} - {{/let}} - - {{/let}} - {{/each-in}} + + + {{#let (this.searchDropdown dropdownItems searchProperty) as |matchingItems|}} + {{#each matchingItems as |item|}} + + {{item}} + + {{else}} + + {{/each}} + {{/let}} + + {{/let}} + {{/each-in}} + - + - + + Filters applied: {{#if this.anyFilters}} - Filters applied: - {{! render tags based on applied @filters and not the internally tracked properties }} - {{#each-in @appliedFilters as |filter value|}} + {{#each-in this.filterProps as |filter value|}} {{#if value}}
    {{/if}} {{/each-in}} - + {{else}} + None {{/if}} - \ No newline at end of file + + +{{#if this.filterAlert}} + + {{this.filterAlert}} + +{{/if}} \ No newline at end of file diff --git a/ui/app/components/clients/filter-toolbar.ts b/ui/app/components/clients/filter-toolbar.ts index b9ef62e1eb..8b2dea5fa1 100644 --- a/ui/app/components/clients/filter-toolbar.ts +++ b/ui/app/components/clients/filter-toolbar.ts @@ -7,42 +7,44 @@ import Component from '@glimmer/component'; import { cached, tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { debounce } from '@ember/runloop'; -import { ClientFilters, type ClientFilterTypes, filterIsSupported } from 'core/utils/client-count-utils'; +import { capitalize } from '@ember/string'; +import { ClientFilters, type ClientFilterTypes } from 'core/utils/client-count-utils'; import type { HTMLElementEvent } from 'vault/forms'; + interface Args { - appliedFilters: Record; - // the dataset objects have more keys than the client filter types, but at minimum they have ClientFilterTypes + filterQueryParams: Record; + // Dataset objects technically have more keys than the client filter types, but at minimum they contain ClientFilterTypes dataset: Record[]; onFilter: CallableFunction; } +// Correspond to each search input's tracked variable in the component class type SearchProperty = 'namespacePathSearch' | 'mountPathSearch' | 'mountTypeSearch'; export default class ClientsFilterToolbar extends Component { - filterTypes = ClientFilters; + filterTypes = Object.values(ClientFilters); + // Tracked filter values @tracked namespace_path: string; @tracked mount_path: string; @tracked mount_type: string; + // Tracked search inputs @tracked namespacePathSearch = ''; @tracked mountPathSearch = ''; @tracked mountTypeSearch = ''; constructor(owner: unknown, args: Args) { super(owner, args); - const { namespace_path, mount_path, mount_type } = this.args.appliedFilters; + const { namespace_path, mount_path, mount_type } = this.args.filterQueryParams; this.namespace_path = namespace_path || ''; this.mount_path = mount_path || ''; this.mount_type = mount_type || ''; } get anyFilters() { - return ( - Object.keys(this.args.appliedFilters).every((f) => filterIsSupported(f)) && - Object.values(this.args.appliedFilters).some((v) => !!v) - ); + return Object.values(this.filterProps).some((v) => !!v); } @cached @@ -61,39 +63,72 @@ export default class ClientsFilterToolbar extends Component { }); return { - [this.filterTypes.NAMESPACE]: [...namespacePaths], - [this.filterTypes.MOUNT_PATH]: [...mountPaths], - [this.filterTypes.MOUNT_TYPE]: [...mountTypes], + [ClientFilters.NAMESPACE]: [...namespacePaths], + [ClientFilters.MOUNT_PATH]: [...mountPaths], + [ClientFilters.MOUNT_TYPE]: [...mountTypes], }; } @cached get dropdownConfig() { return { - [this.filterTypes.NAMESPACE]: { + [ClientFilters.NAMESPACE]: { label: 'namespace', - dropdownItems: this.dropdownItems[this.filterTypes.NAMESPACE], + dropdownItems: this.dropdownItems[ClientFilters.NAMESPACE], searchProperty: 'namespacePathSearch', }, - [this.filterTypes.MOUNT_PATH]: { + [ClientFilters.MOUNT_PATH]: { label: 'mount path', - dropdownItems: this.dropdownItems[this.filterTypes.MOUNT_PATH], + dropdownItems: this.dropdownItems[ClientFilters.MOUNT_PATH], searchProperty: 'mountPathSearch', }, - [this.filterTypes.MOUNT_TYPE]: { + [ClientFilters.MOUNT_TYPE]: { label: 'mount type', - dropdownItems: this.dropdownItems[this.filterTypes.MOUNT_TYPE], + dropdownItems: this.dropdownItems[ClientFilters.MOUNT_TYPE], searchProperty: 'mountTypeSearch', }, }; } + // It's possible that a query param may not exist in the dropdown, in which case show an alert + get filterAlert() { + const alert = (label: string, filter: string) => + `${capitalize(label)} "${filter}" not found in the current data.`; + return this.filterTypes + .flatMap((f: ClientFilters) => { + const filterValue = this.filterProps[f]; + const inDropdown = this.dropdownItems[f].includes(filterValue); + return !inDropdown && filterValue ? [alert(this.dropdownConfig[f].label, filterValue)] : []; + }) + .join(' '); + } + + // the cached decorator recomputes this getter every time the tracked properties + // update instead of every time it is accessed + @cached + get filterProps() { + return this.filterTypes.reduce( + (obj, filterType) => { + obj[filterType] = this[filterType]; + return obj; + }, + {} as Record + ); + } + @action - updateFilter(filterProperty: ClientFilterTypes, value: string, close: CallableFunction) { + handleFilterSelect(filterProperty: ClientFilterTypes, value: string, close: CallableFunction) { this[filterProperty] = value; close(); } + @action + handleDropdownClose(searchProperty: SearchProperty) { + // reset search input for that dropdown + this.updateSearch(searchProperty, ''); + this.applyFilters(); + } + @action clearFilters(filterProperty: ClientFilterTypes | '') { if (filterProperty) { @@ -103,17 +138,13 @@ export default class ClientsFilterToolbar extends Component { this.mount_path = ''; this.mount_type = ''; } - // Fire callback so URL query params update when filters are cleared this.applyFilters(); } @action applyFilters() { - this.args.onFilter({ - namespace_path: this.namespace_path, - mount_path: this.mount_path, - mount_type: this.mount_type, - }); + // Fire callback so URL query params match selected filters + this.args.onFilter(this.filterProps); } @action diff --git a/ui/app/components/clients/page/client-list.hbs b/ui/app/components/clients/page/client-list.hbs index 2be2cd64ef..4d2350124f 100644 --- a/ui/app/components/clients/page/client-list.hbs +++ b/ui/app/components/clients/page/client-list.hbs @@ -3,16 +3,53 @@ SPDX-License-Identifier: BUSL-1.1 }} - + <:subheader> <:table> - {{! table }} + + {{#each-in this.exportDataByTab as |tabName exportData|}} + {{#let (this.filterData exportData) as |tableData|}} + {{tabName}} + +
    + {{#if this.anyFilters}} + + Summary: + {{pluralize tableData.length "client"}} + {{if (eq tableData.length 1) "matches" "match"}} + the filter criteria. + + {{/if}} + + {{! Elements "behind" tabs always render on the DOM and are just superficially hidden/shown. }} + {{! The export data can be many rows so for performance only render the currently selected tab }} + {{#if (eq tabName this.selectedTab)}} + + <:emptyState> + + + + + + + {{/if}} +
    +
    + {{/let}} + {{/each-in}} +
    \ No newline at end of file diff --git a/ui/app/components/clients/page/client-list.ts b/ui/app/components/clients/page/client-list.ts index 676682e0ea..a93d8a77b6 100644 --- a/ui/app/components/clients/page/client-list.ts +++ b/ui/app/components/clients/page/client-list.ts @@ -3,6 +3,103 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import ActivityComponent from '../activity'; +import ActivityComponent, { Args } from '../activity'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { HTMLElementEvent } from 'vault/forms'; -export default class ClientsClientListPageComponent extends ActivityComponent {} +import { filterIsSupported, filterTableData, type ActivityExportData } from 'core/utils/client-count-utils'; + +// Define the base mapping to derive types from +const CLIENT_TYPE_MAP = { + entity: 'Entity', + 'non-entity-token': 'Non-entity', + 'pki-acme': 'ACME', + 'secret-sync': 'Secret sync', +} as const; + +// Dynamically derive the tab values from the mapping +type ClientListTabs = (typeof CLIENT_TYPE_MAP)[keyof typeof CLIENT_TYPE_MAP]; + +export default class ClientsClientListPageComponent extends ActivityComponent { + @tracked selectedTab: ClientListTabs; + @tracked exportDataByTab; + + constructor(owner: unknown, args: Args) { + super(owner, args); + + this.exportDataByTab = this.args.exportData.reduce( + (obj, data) => { + const clientLabel = CLIENT_TYPE_MAP[data.client_type]; + if (!obj[clientLabel]) { + obj[clientLabel] = []; + } + obj[clientLabel].push(data); + return obj; + }, + {} as Record + ); + + const firstTab = Object.keys(this.exportDataByTab)[0] as ClientListTabs; + this.selectedTab = firstTab; + } + + get selectedTabIndex() { + return Object.keys(this.exportDataByTab).indexOf(this.selectedTab); + } + + // Only render tabs for whatever the export data returns + get tabs(): ClientListTabs[] { + return Object.keys(this.exportDataByTab) as ClientListTabs[]; + } + + @action + onClickTab(_event: HTMLElementEvent, idx: number) { + const tab = this.tabs[idx]; + this.selectedTab = tab ?? this.tabs[0]!; + } + + get anyFilters() { + return ( + Object.keys(this.args.filterQueryParams).every((f) => filterIsSupported(f)) && + Object.values(this.args.filterQueryParams).some((v) => !!v) + ); + } + + // TEMPLATE HELPERS + filterData = (dataset: ActivityExportData[]) => filterTableData(dataset, this.args.filterQueryParams); + + tableColumns(tab: ClientListTabs) { + // all client types have values for these columns + const defaultColumns = [ + { key: 'client_id', label: 'Client ID' }, + { key: 'client_type', label: 'Client type' }, + { key: 'namespace_path', label: 'Namespace path' }, + { key: 'namespace_id', label: 'Namespace ID' }, + { + key: 'client_first_used_time', + label: 'Initial usage', + tooltip: 'When the client ID was first used in the selected billing period.', + }, + { key: 'mount_path', label: 'Mount path' }, + { key: 'mount_type', label: 'Mount type' }, + { key: 'mount_accessor', label: 'Mount accessor' }, + ]; + // these params only have value for "entity" client types + const entityOnly = [ + { + key: 'entity_name', + label: 'Entity name', + tooltip: 'Entity name will be empty in the case of a deleted entity.', + }, + { key: 'entity_alias_name', label: 'Entity alias name' }, + { key: 'local_entity_alias', label: 'Local entity alias' }, + { key: 'policies', label: 'Policies' }, + { key: 'entity_metadata', label: 'Entity metadata' }, + { key: 'entity_alias_metadata', label: 'Entity alias metadata' }, + { key: 'entity_alias_custom_metadata', label: 'Entity alias custom metadata' }, + { key: 'entity_group_ids', label: 'Entity group IDs' }, + ]; + return tab === 'Entity' ? [...defaultColumns, ...entityOnly] : defaultColumns; + } +} diff --git a/ui/app/components/clients/page/counts.hbs b/ui/app/components/clients/page/counts.hbs index 042e385bb6..088d21523d 100644 --- a/ui/app/components/clients/page/counts.hbs +++ b/ui/app/components/clients/page/counts.hbs @@ -15,7 +15,7 @@ />
    -
    +
    {{#if (eq @activity.id "no-data")}} @@ -71,7 +71,10 @@ {{/if}} - + {{#if this.version.isEnterprise}} + {{! The "Client list" tab only renders for enterprise versions so there is no need for the nav bar }} + + {{/if}} {{! CLIENT COUNT PAGE COMPONENTS RENDER HERE }} {{yield}} diff --git a/ui/app/components/clients/page/overview.hbs b/ui/app/components/clients/page/overview.hbs index 586b151780..f2dfdba552 100644 --- a/ui/app/components/clients/page/overview.hbs +++ b/ui/app/components/clients/page/overview.hbs @@ -37,7 +37,7 @@
    {{/if}} diff --git a/ui/app/components/clients/table.hbs b/ui/app/components/clients/table.hbs index 464a7d90f4..bef8bc7f34 100644 --- a/ui/app/components/clients/table.hbs +++ b/ui/app/components/clients/table.hbs @@ -21,8 +21,15 @@ {{#let (get B.data key) as |value|}} {{#if (and (eq key "mount_type") (eq value "deleted mount"))}} + {{else if (eq key "client_id")}} + + + {{else}} - {{value}} + + {{! stringify value if it is an array or object, otherwise render directly }} + {{if (this.isObject value) (stringify value) value}} + {{/if}} {{/let}} {{/each}} diff --git a/ui/app/components/clients/table.ts b/ui/app/components/clients/table.ts index 4907ea15ad..5f4d72e43f 100644 --- a/ui/app/components/clients/table.ts +++ b/ui/app/components/clients/table.ts @@ -129,4 +129,7 @@ export default class ClientsTable extends Component { this.sortColumn = column; this.sortDirection = direction; } + + // TEMPLATE HELPERS + isObject = (value: any) => typeof value === 'object'; } diff --git a/ui/app/router.js b/ui/app/router.js index 4e81dac848..76c2c77993 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -41,10 +41,7 @@ Router.map(function () { this.route('clients', function () { this.route('counts', function () { this.route('overview'); - // TODO remove this conditional when client count feature work for 1.21 is complete - if (config.environment !== 'production') { - this.route('client-list'); - } + this.route('client-list'); }); this.route('config'); this.route('edit'); diff --git a/ui/app/styles/core/charts.scss b/ui/app/styles/core/charts.scss index 55f616fa64..bc2d81fbcf 100644 --- a/ui/app/styles/core/charts.scss +++ b/ui/app/styles/core/charts.scss @@ -88,7 +88,7 @@ $fourth: #6cc5b0; } .lineal-chart-bar { - fill: var(--token-color-palette-single); + fill: $single; } .lineal-axis { diff --git a/ui/app/styles/helper-classes/general.scss b/ui/app/styles/helper-classes/general.scss index 0b099615bb..8ecd7eb651 100644 --- a/ui/app/styles/helper-classes/general.scss +++ b/ui/app/styles/helper-classes/general.scss @@ -117,6 +117,10 @@ text-overflow: ellipsis; } +.white-space-nowrap { + white-space: nowrap; +} + // screen reader only .sr-only { border: 0; diff --git a/ui/lib/core/addon/utils/client-count-utils.ts b/ui/lib/core/addon/utils/client-count-utils.ts index b2d4bfd204..ef07b88167 100644 --- a/ui/lib/core/addon/utils/client-count-utils.ts +++ b/ui/lib/core/addon/utils/client-count-utils.ts @@ -35,6 +35,12 @@ export enum ClientFilters { export type ClientFilterTypes = (typeof ClientFilters)[keyof typeof ClientFilters]; +// client_type in the exported activity data differs slightly from the types of client keys +// returned by sys/internal/counters/activity endpoint (: +export const EXPORT_CLIENT_TYPES = ['non-entity-token', 'pki-acme', 'secret-sync', 'entity'] as const; + +export type ActivityExportClientTypes = (typeof EXPORT_CLIENT_TYPES)[number]; + // returns array of VersionHistoryModels for noteworthy upgrades: 1.9, 1.10 // that occurred between timestamps (i.e. queried activity data) export const filterVersionHistory = ( @@ -159,10 +165,16 @@ export const sortMonthsByTimestamp = (monthsArray: ActivityMonthBlock[]) => { ); }; -export const filterTableData = ( - data: MountClients[], +// *Performance note* +// The client dashboard renders dropdown lists that specify filters. When the user selects a dropdown item (filter) +// it updates the query param and this method is called to filter the data passed to the displayed table. +// This method is not doing anything computationally expensive so it should be fine for filtering up to 50K rows of data. +// If activity data (either the by_namespace list or rows of data in the activity export API) grow past that, then we +// will want to look at converting this to a restartable task or do something else :) +export function filterTableData( + data: MountClients[] | ActivityExportData[], filters: Record -): MountClients[] => { +): MountClients[] | ActivityExportData[] { // Return original data if no filters are specified if (!filters || Object.values(filters).every((v) => !v)) { return data; @@ -174,9 +186,25 @@ export const filterTableData = ( // If no filter is specified for that key, return true if (!filterValue) return true; // Otherwise only return true if the datum matches the filter - return datum[filterKey as ClientFilterTypes] === filterValue; + return matchesFilter(datum, filterKey as ClientFilterTypes, filterValue); }); - }); + }) as typeof data; +} + +const matchesFilter = ( + datum: MountClients | ActivityExportData, + filterKey: ClientFilterTypes, + filterValue: string +) => { + const datumValue = datum[filterKey]; + // The API returns and empty string as the namespace_path for the "root" namespace. + // When a user selects "root" as a namespace filter we need to match the datum value + // as either an empty string (for the activity export data) OR as "root" + // (the by_namespace data is serialized to make "root" the namespace_path). + if (filterKey === 'namespace_path' && filterValue === 'root') { + return datumValue === '' || datumValue === filterValue; + } + return datumValue === filterValue; }; export const flattenMounts = (namespaceArray: ByNamespaceClients[]) => @@ -273,7 +301,7 @@ export interface MountNewClients extends TotalClientsSometimes { // Serialized data from activity/export API export interface ActivityExportData { client_id: string; - client_type: string; + client_type: ActivityExportClientTypes; namespace_id: string; namespace_path: string; mount_accessor: string; diff --git a/ui/mirage/handlers/clients.js b/ui/mirage/handlers/clients.js index 4f1fd98501..d0ec77b1e8 100644 --- a/ui/mirage/handlers/clients.js +++ b/ui/mirage/handlers/clients.js @@ -84,7 +84,7 @@ function generateNamespaceBlock(idx = 0, isLowerCounts = false, ns, skipCounts = const max = isLowerCounts ? 100 : 1000; const nsBlock = { namespace_id: ns?.namespace_id || (idx === 0 ? 'root' : Math.random().toString(36).slice(2, 7) + idx), - namespace_path: ns?.namespace_path || (idx === 0 ? '' : `ns${idx}`), + namespace_path: ns?.namespace_path || (idx === 0 ? '' : `ns${idx}/`), counts: {}, mounts: {}, }; @@ -295,15 +295,17 @@ export default function (server) { const activities = schema['clients/activities']; const namespace = req.requestHeaders['X-Vault-Namespace']; let { start_time, end_time } = req.queryParams; - if (!start_time && !end_time) { + if (!start_time) { // if there are no date query params, the activity log default behavior // queries from the builtin license start timestamp to the current month start_time = LICENSE_START.toISOString(); + } + if (!end_time) { end_time = STATIC_NOW.toISOString(); } // backend returns a timestamp if given unix time, so first convert to timestamp string here - if (!start_time.includes('T')) start_time = fromUnixTime(start_time).toISOString(); - if (!end_time.includes('T')) end_time = fromUnixTime(end_time).toISOString(); + if (!start_time?.includes('T')) start_time = fromUnixTime(start_time).toISOString(); + if (!end_time?.includes('T')) end_time = fromUnixTime(end_time).toISOString(); const record = activities.findBy({ start_time, end_time }); let data; diff --git a/ui/tests/acceptance/clients/counts-test.js b/ui/tests/acceptance/clients/counts-test.js index 81f069a79a..8519b4a79b 100644 --- a/ui/tests/acceptance/clients/counts-test.js +++ b/ui/tests/acceptance/clients/counts-test.js @@ -8,7 +8,7 @@ import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; import clientsHandler, { STATIC_NOW } from 'vault/mirage/handlers/clients'; import sinon from 'sinon'; -import { visit, click, currentURL, fillIn } from '@ember/test-helpers'; +import { visit, currentURL } from '@ember/test-helpers'; import { login } from 'vault/tests/helpers/auth/auth-helpers'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { CLIENT_COUNT } from 'vault/tests/helpers/clients/client-count-selectors'; @@ -43,35 +43,6 @@ module('Acceptance | clients | counts', function (hooks) { assert.strictEqual(currentURL(), '/vault/clients/counts/overview', 'Redirects to counts overview route'); }); - test('it should persist filter query params between child routes', async function (assert) { - this.owner.lookup('service:version').type = 'community'; - await visit('/vault/clients/counts/overview'); - await click(CLIENT_COUNT.dateRange.edit); - await fillIn(CLIENT_COUNT.dateRange.editDate('start'), '2023-03'); - await fillIn(CLIENT_COUNT.dateRange.editDate('end'), '2023-10'); - await click(GENERAL.submitButton); - assert.strictEqual( - currentURL(), - '/vault/clients/counts/overview?end_time=1698710400&start_time=1677628800', - 'Start and end times added as query params' - ); - - await click(GENERAL.tab('client list')); - assert.strictEqual( - currentURL(), - '/vault/clients/counts/client-list?end_time=1698710400&start_time=1677628800', - 'Start and end times persist through child route change' - ); - - await click(GENERAL.navLink('Dashboard')); - await click(GENERAL.navLink('Client Count')); - assert.strictEqual( - currentURL(), - '/vault/clients/counts/overview', - 'Query params are reset when exiting route' - ); - }); - test('it should render empty state if no permission to query activity data', async function (assert) { assert.expect(2); server.get('/sys/internal/counters/activity', () => { diff --git a/ui/tests/acceptance/clients/counts/client-list-test.js b/ui/tests/acceptance/clients/counts/client-list-test.js index 5f1b409f19..4a42e8fd37 100644 --- a/ui/tests/acceptance/clients/counts/client-list-test.js +++ b/ui/tests/acceptance/clients/counts/client-list-test.js @@ -20,6 +20,10 @@ module('Acceptance | clients | counts | client list', function (hooks) { setupMirage(hooks); hooks.beforeEach(async function () { + // This tab is hidden on community so the version is stubbed for consistent test running on either version + this.version = this.owner.lookup('service:version'); + this.version.type === 'enterprise'; + // The activity export endpoint returns a ReadableStream of json lines, this is not easily mocked using mirage. // Stubbing the adapter method return instead. const mockResponse = { @@ -39,6 +43,11 @@ module('Acceptance | clients | counts | client list', function (hooks) { this.exportDataStub.restore(); }); + test('it hides client list tab on community', async function (assert) { + this.version.type === 'community'; + assert.dom(GENERAL.tab('client list')).doesNotExist(); + }); + test('it navigates to client list tab', async function (assert) { assert.expect(3); await click(GENERAL.navLink('Client Count')); @@ -51,7 +60,7 @@ module('Acceptance | clients | counts | client list', function (hooks) { test('filters are preset if URL includes query params', async function (assert) { assert.expect(4); - const ns = 'test-ns-2/'; + const ns = 'ns2/'; const mPath = 'auth/userpass/'; const mType = 'userpass'; await visit( @@ -65,7 +74,7 @@ module('Acceptance | clients | counts | client list', function (hooks) { test('selecting filters update URL query params', async function (assert) { assert.expect(3); - const ns = 'test-ns-2/'; + const ns = 'ns2/'; const mPath = 'auth/userpass/'; const mType = 'userpass'; const url = '/vault/clients/counts/client-list'; @@ -80,7 +89,6 @@ module('Acceptance | clients | counts | client list', function (hooks) { // select mount type await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)); await click(FILTERS.dropdownItem(mType)); - await click(GENERAL.button('Apply filters')); assert.strictEqual( currentURL(), `${url}?mount_path=${encodeURIComponent(mPath)}&mount_type=${mType}&namespace_path=${encodeURIComponent( diff --git a/ui/tests/acceptance/clients/counts/overview-test.js b/ui/tests/acceptance/clients/counts/overview-test.js index 4b5c8e82c8..c58738e6bd 100644 --- a/ui/tests/acceptance/clients/counts/overview-test.js +++ b/ui/tests/acceptance/clients/counts/overview-test.js @@ -196,7 +196,6 @@ module('Acceptance | clients | overview', function (hooks) { await click(FILTERS.dropdownItem(mount_path)); await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)); await click(FILTERS.dropdownItem(mount_type)); - await click(GENERAL.button('Apply filters')); assert.strictEqual( currentURL(), `${url}?mount_path=${encodeURIComponent( @@ -222,7 +221,6 @@ module('Acceptance | clients | overview', function (hooks) { await click(FILTERS.dropdownItem(mount_path)); await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)); await click(FILTERS.dropdownItem(mount_type)); - await click(GENERAL.button('Apply filters')); assert.dom(GENERAL.tableRow()).exists({ count: 1 }, 'it only renders the filtered table row'); await click(FILTERS.clearTag(namespace_path)); assert.strictEqual( @@ -254,7 +252,6 @@ module('Acceptance | clients | overview', function (hooks) { await click(FILTERS.dropdownItem(mount_path)); await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)); await click(FILTERS.dropdownItem(mount_type)); - await click(GENERAL.button('Apply filters')); assert.strictEqual( currentURL(), `${url}?mount_path=${encodeURIComponent( diff --git a/ui/tests/helpers/clients/client-count-helpers.js b/ui/tests/helpers/clients/client-count-helpers.js index 2e8d605557..9bb2941957 100644 --- a/ui/tests/helpers/clients/client-count-helpers.js +++ b/ui/tests/helpers/clients/client-count-helpers.js @@ -1119,25 +1119,61 @@ export const SERIALIZED_ACTIVITY_RESPONSE = { ], }; -export const ACTIVITY_EXPORT_STUB = ` -{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"46dcOXXH+P1VEQiKTQjtWXEtBlbHdMOWwz+svXf3xuU=","client_type":"non-entity-token","namespace_id":"whUNi","namespace_path":"test-ns-2/","mount_accessor":"auth_ns_token_3b2bf405","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:21Z","client_first_used_time":"2025-08-15T16:19:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} -{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"VKAJVITyTwyqF1GUzwYHwkaK6bbnL1zN8ZJ7viKR8no=","client_type":"non-entity-token","namespace_id":"omjn8","namespace_path":"test-ns-8/","mount_accessor":"auth_ns_token_07b90be7","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:22Z","client_first_used_time":"2025-08-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +export const ENTITY_EXPORT = `{"entity_name":"entity_b3e2a7ff","entity_alias_name":"bob","local_entity_alias":false,"client_id":"5692c6ef-c871-128e-fb06-df2be7bfc0db","client_type":"entity","namespace_id":"root","namespace_path":"","mount_accessor":"auth_userpass_f47ad0b4","mount_type":"userpass","mount_path":"auth/userpass/","token_creation_time":"2025-08-15T23:48:09Z","client_first_used_time":"2025-08-15T23:48:09Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":["7537e6b7-3b06-65c2-1fb2-c83116eb5e6f"]} +{"entity_name":"bob-smith","entity_alias_name":"bob","local_entity_alias":false,"client_id":"23a04911-5d72-ba98-11d3-527f2fcf3a81","client_type":"entity","namespace_id":"root","namespace_path":"","mount_accessor":"auth_userpass_de28062c","mount_type":"userpass","mount_path":"auth/userpass-test/","token_creation_time":"2025-08-15T23:52:38Z","client_first_used_time":"2025-08-15T23:53:19Z","policies":["base"],"entity_metadata":{"organization":"ACME Inc.","team":"QA"},"entity_alias_metadata":{},"entity_alias_custom_metadata":{"account":"Tester Account"},"entity_group_ids":["7537e6b7-3b06-65c2-1fb2-c83116eb5e6f"]} +{"entity_name":"alice-johnson","entity_alias_name":"alice","local_entity_alias":false,"client_id":"a7c8d912-4f61-23b5-88e4-627a3dcf2b92","client_type":"entity","namespace_id":"root","namespace_path":"","mount_accessor":"auth_userpass_f47ad0b4","mount_type":"userpass","mount_path":"auth/userpass/","token_creation_time":"2025-08-16T09:15:42Z","client_first_used_time":"2025-08-16T09:16:03Z","policies":["admin","audit"],"entity_metadata":{"organization":"TechCorp","team":"DevOps","location":"San Francisco"},"entity_alias_metadata":{"department":"Engineering"},"entity_alias_custom_metadata":{"role":"Senior Engineer"},"entity_group_ids":["7537e6b7-3b06-65c2-1fb2-c83116eb5e6f","a1b2c3d4-5e6f-7g8h-9i0j-k1l2m3n4o5p6"]} +{"entity_name":"charlie-brown","entity_alias_name":"charlie","local_entity_alias":true,"client_id":"b9e5f824-7c92-34d6-a1f8-738b4ecf5d73","client_type":"entity","namespace_id":"whUNi","namespace_path":"ns2/","mount_accessor":"auth_ldap_8a3b9c2d","mount_type":"ldap","mount_path":"auth/ldap/","token_creation_time":"2025-08-16T14:22:17Z","client_first_used_time":"2025-08-16T14:22:45Z","policies":["developer","read-only"],"entity_metadata":{"organization":"StartupXYZ","team":"Backend"},"entity_alias_metadata":{"cn":"charlie.brown","ou":"development"},"entity_alias_custom_metadata":{"project":"microservices"},"entity_group_ids":["c7d8e9f0-1a2b-3c4d-5e6f-789012345678"]} +{"entity_name":"diana-prince","entity_alias_name":"diana","local_entity_alias":false,"client_id":"e4f7a935-2b68-47c9-b3e6-849c5dfb7a84","client_type":"entity","namespace_id":"aT9S5","namespace_path":"ns1/","mount_accessor":"auth_oidc_1f2e3d4c","mount_type":"oidc","mount_path":"auth/oidc/","token_creation_time":"2025-08-17T11:08:33Z","client_first_used_time":"2025-08-17T11:09:01Z","policies":["security","compliance"],"entity_metadata":{"organization":"SecureTech","team":"Security","clearance":"high"},"entity_alias_metadata":{"email":"diana.prince@securetech.com"},"entity_alias_custom_metadata":{"access_level":"L4"},"entity_group_ids":["f8e7d6c5-4b3a-2918-7654-321098765432"]} +{"entity_name":"frank-castle","entity_alias_name":"frank","local_entity_alias":false,"client_id":"c6b9d248-5a71-39e4-c7f2-951d8eaf6b95","client_type":"entity","namespace_id":"root","namespace_path":"","mount_accessor":"auth_jwt_9d8c7b6a","mount_type":"jwt","mount_path":"auth/jwt/","token_creation_time":"2025-08-17T16:43:28Z","client_first_used_time":"2025-08-17T16:44:12Z","policies":["operations","monitoring"],"entity_metadata":{"organization":"CloudOps","team":"SRE","region":"us-east-1"},"entity_alias_metadata":{"sub":"frank.castle@cloudops.io","iss":"https://auth.cloudops.io"},"entity_alias_custom_metadata":{"on_call":"true","expertise":"kubernetes"},"entity_group_ids":["9a8b7c6d-5e4f-3210-9876-543210fedcba"]} +{"entity_name":"grace-hopper","entity_alias_name":"grace","local_entity_alias":true,"client_id":"d8a3e517-6f94-42b7-d5c8-062f9bce4a73","client_type":"entity","namespace_id":"YMjS8","namespace_path":"ns5/","mount_accessor":"auth_userpass_3e2d1c0b","mount_type":"userpass","mount_path":"auth/userpass-legacy/","token_creation_time":"2025-08-18T08:17:55Z","client_first_used_time":"2025-08-18T08:18:23Z","policies":["legacy-admin","data-access"],"entity_metadata":{"organization":"LegacySystems","team":"Platform","tenure":"senior"},"entity_alias_metadata":{"legacy_id":"grace.hopper.001"},"entity_alias_custom_metadata":{"system_access":"mainframe","certification":"vault-admin"},"entity_group_ids":["1f2e3d4c-5b6a-7980-1234-567890abcdef"]} +`; + +const NON_ENTITY_EXPORT = `{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"46dcOXXH+P1VEQiKTQjtWXEtBlbHdMOWwz+svXf3xuU=","client_type":"non-entity-token","namespace_id":"whUNi","namespace_path":"ns2/","mount_accessor":"auth_ns_token_3b2bf405","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:21Z","client_first_used_time":"2025-08-15T16:19:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"VKAJVITyTwyqF1GUzwYHwkaK6bbnL1zN8ZJ7viKR8no=","client_type":"non-entity-token","namespace_id":"omjn8","namespace_path":"ns8/","mount_accessor":"auth_ns_token_07b90be7","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:22Z","client_first_used_time":"2025-08-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} {"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"ww4L5n9WE32lPNh3UBgT3JxTDZb1a+m/3jqUffp04tQ=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:23Z","client_first_used_time":"2025-08-15T16:19:23Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} -{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"cBLb9erIROCw7cczXpfkXTOdnZoVwfWF4EAPD9k61lU=","client_type":"non-entity-token","namespace_id":"aT9S5","namespace_path":"test-ns-1/","mount_accessor":"auth_ns_token_62a4e52a","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:21Z","client_first_used_time":"2025-08-15T16:19:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} -{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"KMHoH3Kvr6nnW2ZIs+i37pYvyVtnuaL3DmyVxUL6boI=","client_type":"non-entity-token","namespace_id":"YMjS8","namespace_path":"test-ns-5/","mount_accessor":"auth_ns_token_45cbc810","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:22Z","client_first_used_time":"2025-08-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} -{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"hcMH4P4IGAN13cJqkwIJLXYoPLTodtOj/wPTZKS0x4U=","client_type":"non-entity-token","namespace_id":"ZNdL5","namespace_path":"test-ns-7/","mount_accessor":"auth_ns_token_8bbd9440","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:22Z","client_first_used_time":"2025-08-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} -{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"Oby0ABLmfhqYdfqGfljGHHhAA5zX+BwsGmFu4QGJZd0=","client_type":"non-entity-token","namespace_id":"bJIgY","namespace_path":"test-ns-9/","mount_accessor":"auth_ns_token_8d188479","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:22Z","client_first_used_time":"2025-08-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"cBLb9erIROCw7cczXpfkXTOdnZoVwfWF4EAPD9k61lU=","client_type":"non-entity-token","namespace_id":"aT9S5","namespace_path":"ns1/","mount_accessor":"auth_ns_token_62a4e52a","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:21Z","client_first_used_time":"2025-08-15T16:19:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"KMHoH3Kvr6nnW2ZIs+i37pYvyVtnuaL3DmyVxUL6boI=","client_type":"non-entity-token","namespace_id":"YMjS8","namespace_path":"ns5/","mount_accessor":"auth_ns_token_45cbc810","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:22Z","client_first_used_time":"2025-08-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"hcMH4P4IGAN13cJqkwIJLXYoPLTodtOj/wPTZKS0x4U=","client_type":"non-entity-token","namespace_id":"ZNdL5","namespace_path":"ns7/","mount_accessor":"auth_ns_token_8bbd9440","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:22Z","client_first_used_time":"2025-08-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"Oby0ABLmfhqYdfqGfljGHHhAA5zX+BwsGmFu4QGJZd0=","client_type":"non-entity-token","namespace_id":"bJIgY","namespace_path":"ns9/","mount_accessor":"auth_ns_token_8d188479","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:22Z","client_first_used_time":"2025-08-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} {"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"Z6MjZuH/VD7HU11efiKoM/hfoxssSbeu4c6DhC7zUZ4=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:23Z","client_first_used_time":"2025-08-15T16:19:23Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} {"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"1UxaPHJUOPWrf0ivMgBURK6WHzbfXGkcn/C/xI3AeHQ=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:24Z","client_first_used_time":"2025-08-15T16:19:24Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} -{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"hfFbwhMucs/f84p2QTOiBLT72i0WLVkIgCGV7RIuWlo=","client_type":"non-entity-token","namespace_id":"x6sKN","namespace_path":"test-ns-4/","mount_accessor":"auth_ns_token_2aaebdc2","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:21Z","client_first_used_time":"2025-08-15T16:19:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} -{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"sOdIr+zoNqOUa4hq6Jv4LCGVr0sTLGbvcRPVGAtUA7g=","client_type":"non-entity-token","namespace_id":"Rsvk5","namespace_path":"test-ns-6/","mount_accessor":"auth_ns_token_f603fd8d","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:22Z","client_first_used_time":"2025-08-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"hfFbwhMucs/f84p2QTOiBLT72i0WLVkIgCGV7RIuWlo=","client_type":"non-entity-token","namespace_id":"x6sKN","namespace_path":"ns4/","mount_accessor":"auth_ns_token_2aaebdc2","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:21Z","client_first_used_time":"2025-08-15T16:19:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"sOdIr+zoNqOUa4hq6Jv4LCGVr0sTLGbvcRPVGAtUA7g=","client_type":"non-entity-token","namespace_id":"Rsvk5","namespace_path":"ns6/","mount_accessor":"auth_ns_token_f603fd8d","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:22Z","client_first_used_time":"2025-08-15T16:19:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} {"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"vOIAwNhe6P6HFdJQgUIU/8K6Z5e+oxyVP5x3KtTKS6U=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:23Z","client_first_used_time":"2025-08-15T16:19:23Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} {"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"ZOkJY3P7IzOqulsnEI0JAQQXwTPnXmpGUh9otqNUclc=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:23Z","client_first_used_time":"2025-08-15T16:19:23Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} {"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"Lsha/HH+xLZq92XG4GYZVlwVQCiqPCUIuoego4aCybU=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:23Z","client_first_used_time":"2025-08-15T16:19:23Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} {"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"Tsl/u7CDTYSXA9HRwlNTW7K/yyEe5PDkLOVTvTWy3q0=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:23Z","client_first_used_time":"2025-08-15T16:19:23Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} {"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"vnq6JntpiGV4FN6GDICLECe2in31aanLA6Q1UWqBmL0=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:24Z","client_first_used_time":"2025-08-15T16:19:24Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} -{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"MRMrywfPPL3QnKFMBGfRjjmaefBRH1VKpQVIfrd0Xb4=","client_type":"non-entity-token","namespace_id":"6aDiU","namespace_path":"test-ns-3/","mount_accessor":"auth_ns_token_ef771c23","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:21Z","client_first_used_time":"2025-08-15T16:19:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"MRMrywfPPL3QnKFMBGfRjjmaefBRH1VKpQVIfrd0Xb4=","client_type":"non-entity-token","namespace_id":"6aDiU","namespace_path":"ns3/","mount_accessor":"auth_ns_token_ef771c23","mount_type":"ns_token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:21Z","client_first_used_time":"2025-08-15T16:19:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} {"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"Rce6fjHs15+hDl5XdXbWmzGNYrTcQsJuaoqfs9Vrhvw=","client_type":"non-entity-token","namespace_id":"root","namespace_path":"","mount_accessor":"auth_token_360f591b","mount_type":"token","mount_path":"auth/token/","token_creation_time":"2025-08-15T16:19:24Z","client_first_used_time":"2025-08-15T16:19:24Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} -{"entity_name":"entity_b3e2a7ff","entity_alias_name":"bob","local_entity_alias":false,"client_id":"5692c6ef-c871-128e-fb06-df2be7bfc0db","client_type":"entity","namespace_id":"root","namespace_path":"","mount_accessor":"auth_userpass_f47ad0b4","mount_type":"userpass","mount_path":"auth/userpass/","token_creation_time":"2025-08-15T23:48:09Z","client_first_used_time":"2025-08-15T23:48:09Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":["7537e6b7-3b06-65c2-1fb2-c83116eb5e6f"]} -{"entity_name":"bob-smith","entity_alias_name":"bob","local_entity_alias":false,"client_id":"23a04911-5d72-ba98-11d3-527f2fcf3a81","client_type":"entity","namespace_id":"root","namespace_path":"","mount_accessor":"auth_userpass_de28062c","mount_type":"userpass","mount_path":"auth/userpass-test/","token_creation_time":"2025-08-15T23:52:38Z","client_first_used_time":"2025-08-15T23:53:19Z","policies":["base"],"entity_metadata":{"organization":"ACME Inc.","team":"QA"},"entity_alias_metadata":{},"entity_alias_custom_metadata":{"account":"Tester Account"},"entity_group_ids":["7537e6b7-3b06-65c2-1fb2-c83116eb5e6f"]} `; + +const ACME_EXPORT = `{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.3U8nSB_yMBvrdu7PvAVykKurDiaH_vQGaEdAUsp-Cew","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:47:54Z","client_first_used_time":"2025-08-21T18:47:54Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.77tKDzxw0i81Nr4XLliTP9xRsztXLTuS16nN32B9jHA","client_type":"pki-acme","namespace_id":"whUNi","namespace_path":"ns2/","mount_accessor":"pki_06dad7b8","mount_type":"ns_pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:48:17Z","client_first_used_time":"2025-08-21T18:48:17Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.RoN77EahLU0wfem4z--ZqJSaqOZ7RvBWR3OkPHM_xaw","client_type":"pki-acme","namespace_id":"omjn8","namespace_path":"ns8/","mount_accessor":"pki_06dad7b8","mount_type":"ns_pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:49:26Z","client_first_used_time":"2025-08-21T18:49:26Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.5S7vaaJIrXormQSLHv4YkBhVfu6Ug0GERhTVTCrq-Fk","client_type":"pki-acme","namespace_id":"aT9S5","namespace_path":"ns1/","mount_accessor":"pki_06dad7b8","mount_type":"ns_pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:45:12Z","client_first_used_time":"2025-08-21T18:45:12Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.IeJQMwtkReJHVNL6fZLmqiu8-Re4JdKCQixXkfcaSRE","client_type":"pki-acme","namespace_id":"YMjS8","namespace_path":"ns5/","mount_accessor":"pki_06dad7b8","mount_type":"ns_pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:45:41Z","client_first_used_time":"2025-08-21T18:45:41Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.vTm3SCZom90qy3SuyIacpVsQgGLx7ASf3SeGpqn5XBA","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:47:19Z","client_first_used_time":"2025-08-21T18:47:19Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.64jWs15k6roUH6MiQ2u80K08Bmqw8IQOpqTpDZgZ1f4","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:47:25Z","client_first_used_time":"2025-08-21T18:47:25Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.RkjnwyIIn6bnc4LDdKQ9HNfnhuVXT7vQONXgGHJl4CE","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:49:21Z","client_first_used_time":"2025-08-21T18:49:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.uozIMLVXDMU7Fc2TFFwq0-uE1GFSui5rbTI1XyNAYBY","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:44:44Z","client_first_used_time":"2025-08-21T18:44:44Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.WiLdlzq93WtVmObB__CC2SPX6sI7EVLTTzxOIRHHN3o","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:44:49Z","client_first_used_time":"2025-08-21T18:44:49Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.P65jgamzwLYbKyxTlJFD5DL3sIUbusbXcQhYaysgzlU","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:45:59Z","client_first_used_time":"2025-08-21T18:45:59Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.2REWUkDLXAG2UB0ZJQcjPnHc4H39aq8fG3LMaHSHKow","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:46:05Z","client_first_used_time":"2025-08-21T18:46:05Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.Eeyq9-EfWv-iE9Aj3DzCU4r9P8V1Maewx51vcxMN-jA","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:46:10Z","client_first_used_time":"2025-08-21T18:46:10Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.vaeb2KR58sRuMUdUlv2TsbaOkSICTAxmJxhkuOs8ZiM","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:46:22Z","client_first_used_time":"2025-08-21T18:46:22Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.xEPG0eNfrAfRgXg6AKjsCrFPMs0IbLTCfUsCie_rfzY","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:46:51Z","client_first_used_time":"2025-08-21T18:46:51Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"pki-acme.Bkg4862LEoFXJUDWlfFtJHU9a69KRJPiEdw5XCbkkAI","client_type":"pki-acme","namespace_id":"root","namespace_path":"","mount_accessor":"pki_06dad7b8","mount_type":"pki","mount_path":"pki_int/","token_creation_time":"2025-08-21T18:47:42Z","client_first_used_time":"2025-08-21T18:47:42Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +`; + +const SECRET_SYNC_EXPORT = `{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.3U8nSB_yMBvrdu7PvAVykKurDiaH_vQGaEdAUsp-Cew","client_type":"secret-sync","namespace_id":"root","namespace_path":"","mount_accessor":"kv_06dad7b8","mount_type":"kv","mount_path":"secrets/kv/0","token_creation_time":"2025-08-21T18:47:54Z","client_first_used_time":"2025-08-21T18:47:54Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.77tKDzxw0i81Nr4XLliTP9xRsztXLTuS16nN32B9jHA","client_type":"secret-sync","namespace_id":"ZNdL5","namespace_path":"ns7/","mount_accessor":"kv_06dad7b8","mount_type":"ns_kv","mount_path":"secrets/kv/0","token_creation_time":"2025-08-21T18:48:17Z","client_first_used_time":"2025-08-21T18:48:17Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.RoN77EahLU0wfem4z--ZqJSaqOZ7RvBWR3OkPHM_xaw","client_type":"secret-sync","namespace_id":"bJIgY","namespace_path":"ns9/","mount_accessor":"kv_12abc3d4","mount_type":"ns_kv","mount_path":"secrets/kv/1","token_creation_time":"2025-08-21T18:49:26Z","client_first_used_time":"2025-08-21T18:49:26Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.5S7vaaJIrXormQSLHv4YkBhVfu6Ug0GERhTVTCrq-Fk","client_type":"secret-sync","namespace_id":"x6sKN","namespace_path":"ns4/","mount_accessor":"kv_06dad7b8","mount_type":"ns_kv","mount_path":"secrets/kv/0","token_creation_time":"2025-08-21T18:45:12Z","client_first_used_time":"2025-08-21T18:45:12Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.IeJQMwtkReJHVNL6fZLmqiu8-Re4JdKCQixXkfcaSRE","client_type":"secret-sync","namespace_id":"Rsvk5","namespace_path":"ns6/","mount_accessor":"kv_12abc3d4","mount_type":"ns_kv","mount_path":"secrets/kv/1","token_creation_time":"2025-08-21T18:45:41Z","client_first_used_time":"2025-08-21T18:45:41Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.vTm3SCZom90qy3SuyIacpVsQgGLx7ASf3SeGpqn5XBA","client_type":"secret-sync","namespace_id":"root","namespace_path":"","mount_accessor":"kv_06dad7b8","mount_type":"kv","mount_path":"secrets/kv/0","token_creation_time":"2025-08-21T18:47:19Z","client_first_used_time":"2025-08-21T18:47:19Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.64jWs15k6roUH6MiQ2u80K08Bmqw8IQOpqTpDZgZ1f4","client_type":"secret-sync","namespace_id":"6aDiU","namespace_path":"ns3/","mount_accessor":"kv_12abc3d4","mount_type":"kv","mount_path":"secrets/kv/1","token_creation_time":"2025-08-21T18:47:25Z","client_first_used_time":"2025-08-21T18:47:25Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +{"entity_name":"","entity_alias_name":"","local_entity_alias":false,"client_id":"secret-sync.RkjnwyIIn6bnc4LDdKQ9HNfnhuVXT7vQONXgGHJl4CE","client_type":"secret-sync","namespace_id":"root","namespace_path":"","mount_accessor":"kv_06dad7b8","mount_type":"kv","mount_path":"secrets/kv/0","token_creation_time":"2025-08-21T18:49:21Z","client_first_used_time":"2025-08-21T18:49:21Z","policies":[],"entity_metadata":{},"entity_alias_metadata":{},"entity_alias_custom_metadata":{},"entity_group_ids":[]} +`; + +export const ACTIVITY_EXPORT_STUB = ENTITY_EXPORT + NON_ENTITY_EXPORT + ACME_EXPORT + SECRET_SYNC_EXPORT; diff --git a/ui/tests/helpers/clients/client-count-selectors.ts b/ui/tests/helpers/clients/client-count-selectors.ts index d66bfecd4d..e1f1940033 100644 --- a/ui/tests/helpers/clients/client-count-selectors.ts +++ b/ui/tests/helpers/clients/client-count-selectors.ts @@ -27,11 +27,7 @@ export const CLIENT_COUNT = { statTextValue: (label: string) => label ? `[data-test-stat-text="${label}"] .stat-value` : '[data-test-stat-text]', usageStats: (title: string) => `[data-test-usage-stats="${title}"]`, - filterBar: '[data-test-clients-filter-bar]', - nsFilter: '#namespace-search-select', - mountFilter: '#mounts-search-select', - selectedAuthMount: 'div#mounts-search-select [data-test-selected-option] div', - selectedNs: 'div#namespace-search-select [data-test-selected-option] div', + tableSummary: (tabName: string) => `[data-test-table-summary="${tabName}"]`, upgradeWarning: '[data-test-clients-upgrade-warning]', exportButton: '[data-test-export-button]', }; @@ -60,5 +56,6 @@ export const FILTERS = { dropdownSearch: (name: string) => `[data-test-dropdown="${name}"] input`, tag: (filter?: string, value?: string) => filter && value ? `[data-test-filter-tag="${filter} ${value}"]` : '[data-test-filter-tag]', + tagContainer: '[data-test-filter-tag-container]', clearTag: (value: string) => `[aria-label="Dismiss ${value}"]`, }; diff --git a/ui/tests/integration/components/charts/vertical-bar-basic-test.js b/ui/tests/integration/components/charts/vertical-bar-basic-test.js index 97b9352d39..7ec4a82905 100644 --- a/ui/tests/integration/components/charts/vertical-bar-basic-test.js +++ b/ui/tests/integration/components/charts/vertical-bar-basic-test.js @@ -5,7 +5,7 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'vault/tests/helpers'; -import { render, triggerEvent } from '@ember/test-helpers'; +import { findAll, render, triggerEvent } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; const EXAMPLE = [ @@ -40,12 +40,29 @@ module('Integration | Component | clients/charts/vertical-bar-basic', function ( hooks.beforeEach(function () { this.data = EXAMPLE; + this.showTable = false; + this.renderComponent = async () => { + await render( + hbs`` + ); + }; + }); + + test('it renders bars the expected color', async function (assert) { + await this.renderComponent(); + // the first bar has no data and doesn't render so get the second one + const bars = findAll('.lineal-chart-bar'); + const actualColor = getComputedStyle(bars[1]).fill; + const expectedColor = 'rgb(28, 52, 95)'; + assert.strictEqual( + actualColor, + expectedColor, + `actual color: ${actualColor}, expected color: ${expectedColor}` + ); }); test('it renders when some months have no data', async function (assert) { - await render( - hbs`` - ); + await this.renderComponent(); assert.dom('[data-test-chart="My chart"]').exists('renders chart container'); assert.dom('[data-test-vertical-bar]').exists({ count: 3 }, 'renders 3 vertical bars'); @@ -88,9 +105,7 @@ module('Integration | Component | clients/charts/vertical-bar-basic', function ( secret_syncs: 0, }, ]; - await render( - hbs`` - ); + await this.renderComponent(); assert.dom('[data-test-chart="My chart"]').exists('renders chart container'); assert.dom('[data-test-vertical-bar]').exists({ count: 2 }, 'renders 2 vertical bars'); @@ -108,9 +123,8 @@ module('Integration | Component | clients/charts/vertical-bar-basic', function ( }); test('it renders underlying data', async function (assert) { - await render( - hbs`` - ); + this.showTable = true; + await this.renderComponent(); assert.dom('[data-test-chart="My chart"]').exists('renders chart container'); assert.dom('[data-test-underlying-data]').exists('renders underlying data when showTable=true'); assert diff --git a/ui/tests/integration/components/charts/vertical-bar-stacked-test.js b/ui/tests/integration/components/charts/vertical-bar-stacked-test.js index 9107141b00..183ea40fbb 100644 --- a/ui/tests/integration/components/charts/vertical-bar-stacked-test.js +++ b/ui/tests/integration/components/charts/vertical-bar-stacked-test.js @@ -16,6 +16,7 @@ const EXAMPLE = [ fuji_apples: null, gala_apples: null, red_delicious: null, + honey_crisp: null, }, { timestamp: '2022-10-01T00:00:00', @@ -23,6 +24,7 @@ const EXAMPLE = [ fuji_apples: 1471, gala_apples: 4389, red_delicious: 4207, + honey_crisp: 1234, }, { timestamp: '2022-11-01T00:00:00', @@ -30,6 +32,7 @@ const EXAMPLE = [ fuji_apples: 149, gala_apples: 20, red_delicious: 5802, + honey_crisp: 134, }, ]; @@ -42,13 +45,42 @@ module('Integration | Component | clients/charts/vertical-bar-stacked', function { key: 'fuji_apples', label: 'Fuji counts' }, { key: 'gala_apples', label: 'Gala counts' }, ]; + this.showTable = false; + this.renderComponent = async () => { + await render( + hbs`` + ); + }; + }); + + test('it renders bars the expected color', async function (assert) { + this.legend = [ + { key: 'fuji_apples', label: 'Fuji counts' }, + { key: 'gala_apples', label: 'Gala counts' }, + { key: 'red_delicious', label: 'Red Delicious counts' }, + { key: 'honey_crisp', label: 'Honey Crisp counts' }, + ]; + await this.renderComponent(); + const barClasses = ['.stacked-bar-1', '.stacked-bar-2', '.stacked-bar-3', '.stacked-bar-4']; + const expectedFills = [ + 'rgb(66, 105, 208)', + 'rgb(239, 177, 23)', + 'rgb(255, 114, 92)', + 'rgb(108, 197, 176)', + ]; + barClasses.forEach((className, idx) => { + const bars = findAll(className); + // Skip the first set of bars because they have no data + const bar = bars[1]; + const actual = getComputedStyle(bar).fill; + const expected = expectedFills[idx]; + assert.strictEqual(actual, expected, `${className} has expected fill color: ${expected}`); + }); }); test('it renders when some months have no data', async function (assert) { assert.expect(10); - await render( - hbs`` - ); + await this.renderComponent(); assert.dom(CHARTS.chart('My chart')).exists('renders chart container'); @@ -106,10 +138,7 @@ module('Integration | Component | clients/charts/vertical-bar-stacked', function red_delicious: 180, }, ]; - await render( - hbs`` - ); - + await this.renderComponent(); assert.dom(CHARTS.chart('My chart')).exists('renders chart container'); findAll(CHARTS.verticalBar).forEach((b, idx) => assert.dom(b).isNotVisible(`bar: ${idx} does not render`) @@ -132,9 +161,8 @@ module('Integration | Component | clients/charts/vertical-bar-stacked', function test('it renders underlying data', async function (assert) { assert.expect(3); - await render( - hbs`` - ); + this.showTable = true; + await this.renderComponent(); assert.dom(CHARTS.chart('My chart')).exists('renders chart container'); assert.dom(CHARTS.table).exists('renders underlying data when showTable=true'); assert diff --git a/ui/tests/integration/components/clients/filter-toolbar-test.js b/ui/tests/integration/components/clients/filter-toolbar-test.js index 636adb715f..4699bf76f7 100644 --- a/ui/tests/integration/components/clients/filter-toolbar-test.js +++ b/ui/tests/integration/components/clients/filter-toolbar-test.js @@ -25,19 +25,19 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { { namespace_path: 'ns1/', mount_type: 'ns_token/', mount_path: 'auth/token/' }, ]; this.onFilter = sinon.spy(); - this.appliedFilters = { namespace_path: '', mount_path: '', mount_type: '' }; + this.filterQueryParams = { namespace_path: '', mount_path: '', mount_type: '' }; this.renderComponent = async () => { await render(hbs` `); }; this.presetFilters = () => { - this.appliedFilters = { + this.filterQueryParams = { namespace_path: 'admin/', mount_path: 'auth/userpass-root/', mount_type: 'token/', @@ -63,10 +63,7 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { assert.dom(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)).hasText('Namespace'); assert.dom(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH)).hasText('Mount path'); assert.dom(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)).hasText('Mount type'); - assert.dom(GENERAL.button('Apply filters')).exists(); - assert - .dom(GENERAL.button('Clear filters')) - .doesNotExist('"Clear filters" button does not render when filters are unset'); + assert.dom(FILTERS.tagContainer).hasText('Filters applied: None'); }); test('it renders dropdown items and does not include duplicates', async function (assert) { @@ -138,7 +135,7 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { assert.dom('ul').hasText('userpass/ token/ ns_token/', 'it resets filter and renders all mount types'); }); - test('it searches renders no matches found message', async function (assert) { + test('it searches and renders no matches found message', async function (assert) { await this.renderComponent(); await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)); @@ -171,7 +168,7 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { assert.dom('ul').hasText('No mount types to filter'); }); - test('it renders no items to filter if dataset is missing expected keys', async function (assert) { + test('it renders no items to filter if dataset does not contain expected keys', async function (assert) { this.dataset = [{ foo: null, bar: null, baz: null }]; await this.renderComponent(); await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)); @@ -182,9 +179,26 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { assert.dom('ul').hasText('No mount types to filter'); }); - test('it selects dropdown items', async function (assert) { + test('it selects dropdown items and renders a filter tag', async function (assert) { await this.renderComponent(); - await this.selectFilters(); + + // select namespace + await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)); + await click(FILTERS.dropdownItem('admin/')); + assert.dom(FILTERS.tag(ClientFilters.NAMESPACE, 'admin/')).exists(); + assert.dom(FILTERS.tag()).exists({ count: 1 }, '1 filter tag renders'); + + // select mount path + await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH)); + await click(FILTERS.dropdownItem('auth/userpass-root/')); + assert.dom(FILTERS.tag(ClientFilters.MOUNT_PATH, 'auth/userpass-root/')).exists(); + assert.dom(FILTERS.tag()).exists({ count: 2 }, '2 filter tags render'); + + // select mount type + await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)); + await click(FILTERS.dropdownItem('token/')); + assert.dom(FILTERS.tag(ClientFilters.MOUNT_TYPE, 'token/')).exists(); + assert.dom(FILTERS.tag()).exists({ count: 3 }, '3 filter tags render'); // dropdown closes when an item is selected, reopen each one to assert the correct item is selected await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)); @@ -200,48 +214,33 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { assert.dom(`${FILTERS.dropdownItem('token/')} ${GENERAL.icon('check')}`).exists(); }); - test('it applies filters when no filters are set', async function (assert) { + test('it fires callback when a filter is selected', async function (assert) { await this.renderComponent(); - await this.selectFilters(); - await click(GENERAL.button('Apply filters')); - const [obj] = this.onFilter.lastCall.args; - assert.strictEqual( - obj[ClientFilters.NAMESPACE], - 'admin/', - `onFilter callback has expected "${ClientFilters.NAMESPACE}"` - ); - assert.strictEqual( - obj[ClientFilters.MOUNT_PATH], - 'auth/userpass-root/', - `onFilter callback has expected "${ClientFilters.MOUNT_PATH}"` - ); - assert.strictEqual( - obj[ClientFilters.MOUNT_TYPE], - 'token/', - `onFilter callback has expected "${ClientFilters.MOUNT_TYPE}"` - ); + // select namespace + await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)); + await click(FILTERS.dropdownItem('admin/')); + let lastCall = this.onFilter.lastCall.args[0]; + // this.filterQueryParams has empty values for each filter type + let expectedObject = { ...this.filterQueryParams, [ClientFilters.NAMESPACE]: 'admin/' }; + assert.propEqual(lastCall, expectedObject, `callback includes value for ${ClientFilters.NAMESPACE}`); + + // select mount path + await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH)); + await click(FILTERS.dropdownItem('auth/userpass-root/')); + lastCall = this.onFilter.lastCall.args[0]; + expectedObject = { ...expectedObject, [ClientFilters.MOUNT_PATH]: 'auth/userpass-root/' }; + assert.propEqual(lastCall, expectedObject, `callback includes value for ${ClientFilters.MOUNT_PATH}`); + + // select mount type + await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)); + await click(FILTERS.dropdownItem('token/')); + lastCall = this.onFilter.lastCall.args[0]; + expectedObject = { ...expectedObject, [ClientFilters.MOUNT_TYPE]: 'token/' }; + assert.propEqual(lastCall, expectedObject, `callback includes value for ${ClientFilters.MOUNT_TYPE}`); }); - test('it applies updated filters when filters are preset', async function (assert) { - this.appliedFilters = { namespace_path: 'ns1', mount_path: 'auth/token/', mount_type: 'ns_token/' }; - await this.renderComponent(); - // Check initial filters - await click(GENERAL.button('Apply filters')); - const [beforeUpdate] = this.onFilter.lastCall.args; - assert.propEqual(beforeUpdate, this.appliedFilters, 'callback fires with preset filters'); - // Change filters and confirm callback has updated values - await this.selectFilters(); - await click(GENERAL.button('Apply filters')); - const [afterUpdate] = this.onFilter.lastCall.args; - assert.propEqual( - afterUpdate, - { namespace_path: 'admin/', mount_path: 'auth/userpass-root/', mount_type: 'token/' }, - 'callback fires with updated selection' - ); - }); - - test('it renders a tag for each filter', async function (assert) { + test('it renders filter tags when initialized with @filterQueryParams', async function (assert) { this.presetFilters(); await this.renderComponent(); @@ -249,23 +248,27 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { assert.dom(FILTERS.tag(ClientFilters.NAMESPACE, 'admin/')).exists(); assert.dom(FILTERS.tag(ClientFilters.MOUNT_PATH, 'auth/userpass-root/')).exists(); assert.dom(FILTERS.tag(ClientFilters.MOUNT_TYPE, 'token/')).exists(); - assert - .dom(GENERAL.button('Clear filters')) - .exists('"Clear filters" button renders when filters are present'); }); - test('it resets all filters', async function (assert) { + test('it updates filters tags when initialized with @filterQueryParams', async function (assert) { + this.filterQueryParams = { namespace_path: 'ns1/', mount_path: 'auth/token/', mount_type: 'ns_token/' }; + await this.renderComponent(); + // Check initial filters + assert.dom(FILTERS.tagContainer).hasText('Filters applied: ns1/ auth/token/ ns_token/'); + // Change filters and confirm callback has updated values + await this.selectFilters(); + const [afterUpdate] = this.onFilter.lastCall.args; + assert.propEqual( + afterUpdate, + { namespace_path: 'admin/', mount_path: 'auth/userpass-root/', mount_type: 'token/' }, + 'callback fires with updated selection' + ); + assert.dom(FILTERS.tagContainer).hasText('Filters applied: admin/ auth/userpass-root/ token/'); + }); + + test('it clears all filters', async function (assert) { this.presetFilters(); await this.renderComponent(); - // first check that filters have preset values - await click(GENERAL.button('Apply filters')); - const [beforeClear] = this.onFilter.lastCall.args; - assert.propEqual( - beforeClear, - { namespace_path: 'admin/', mount_path: 'auth/userpass-root/', mount_type: 'token/' }, - 'callback fires with preset filters' - ); - // now clear filters and confirm values are cleared await click(GENERAL.button('Clear filters')); const [afterClear] = this.onFilter.lastCall.args; assert.propEqual( @@ -273,19 +276,12 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { { namespace_path: '', mount_path: '', mount_type: '' }, 'onFilter callback has empty values when "Clear filters" is clicked' ); + assert.dom(FILTERS.tagContainer).hasText('Filters applied: None'); }); test('it clears individual filters', async function (assert) { this.presetFilters(); await this.renderComponent(); - // first check that filters have preset values - await click(GENERAL.button('Apply filters')); - const [beforeClear] = this.onFilter.lastCall.args; - assert.propEqual( - beforeClear, - { namespace_path: 'admin/', mount_path: 'auth/userpass-root/', mount_type: 'token/' }, - 'callback fires with preset filters' - ); await click(FILTERS.clearTag('admin/')); const afterClear = this.onFilter.lastCall.args[0]; assert.propEqual( @@ -295,12 +291,11 @@ module('Integration | Component | clients/filter-toolbar', function (hooks) { ); }); - test('it only renders tags for supported filters', async function (assert) { - this.appliedFilters = { start_time: '2025-08-31T23:59:59Z' }; + test('it renders an alert when initialized with @filterQueryParams that are not present in the dropdown', async function (assert) { + this.filterQueryParams = { namespace_path: 'admin/', mount_path: '', mount_type: 'banana' }; await this.renderComponent(); - assert - .dom(GENERAL.button('Clear filters')) - .doesNotExist('"Clear filters" button does not render when filters are unset'); - assert.dom(FILTERS.tag()).doesNotExist(); + assert.dom(FILTERS.tag()).exists({ count: 2 }, '2 filter tags render'); + assert.dom(FILTERS.tagContainer).hasText('Filters applied: admin/ banana'); + assert.dom(GENERAL.inlineAlert).hasText(`Mount type "banana" not found in the current data.`); }); }); diff --git a/ui/tests/integration/components/clients/page/client-list-test.js b/ui/tests/integration/components/clients/page/client-list-test.js new file mode 100644 index 0000000000..8d16391b7e --- /dev/null +++ b/ui/tests/integration/components/clients/page/client-list-test.js @@ -0,0 +1,263 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'vault/tests/helpers'; +import { click, find, findAll, render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { ACTIVITY_EXPORT_STUB } from 'vault/tests/helpers/clients/client-count-helpers'; +import { CLIENT_COUNT, FILTERS } from 'vault/tests/helpers/clients/client-count-selectors'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; +import sinon from 'sinon'; +import { ClientFilters } from 'core/utils/client-count-utils'; + +const EXPORT_TAB_TO_TYPE = { + Entity: 'entity', + 'Non-entity': 'non-entity-token', + ACME: 'pki-acme', + 'Secret sync': 'secret-sync', +}; + +module('Integration | Component | clients/page/client-list', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(async function () { + this.exportData = ACTIVITY_EXPORT_STUB.trim() + .split('\n') + .map((line) => JSON.parse(line)); + this.onFilterChange = sinon.spy(); + this.filterQueryParams = { namespace_path: '', mount_path: '', mount_type: '' }; + + this.expectedData = (type, { key, value } = {}) => + this.exportData.filter((d) => { + const isClientType = d.client_type === type; + return key && value ? isClientType && d[key] === value : isClientType; + }); + + this.expectedOptions = (type) => [...new Set(this.exportData.map((m) => m[type]))]; + this.expectedNamespaces = this.expectedOptions('namespace_path'); + this.expectedMountPaths = this.expectedOptions('mount_path'); + this.expectedMountTypes = this.expectedOptions('mount_type'); + + this.renderComponent = () => + render(hbs` + `); + + // Filter key is one of ClientFilterTypes + this.assertTabData = async (assert, filterKey, filterValue) => { + // Iterate over each tab and assert rendered table data + for (const [tabName, clientType] of Object.entries(EXPORT_TAB_TO_TYPE)) { + const expectedData = this.expectedData(clientType, { key: filterKey, value: filterValue }); + const length = expectedData.length; + await click(GENERAL.hdsTab(tabName)); + assert + .dom(GENERAL.hdsTab(tabName)) + .hasText(`${tabName} ${length}`, `${tabName} tab counts match dataset length`); + const noun = length === 1 ? 'client' : 'clients'; + const verb = length === 1 ? 'matches' : 'match'; + assert + .dom(CLIENT_COUNT.tableSummary(tabName)) + .hasText(`Summary: ${length} ${noun} ${verb} the filter criteria.`); + assert + .dom(GENERAL.hdsTab(tabName)) + .hasAttribute('aria-selected', 'true', `it selects the tab: ${tabName}`); + assert.dom(GENERAL.tableRow()).exists({ count: length }); + + // Find all rendered rows and assert they satisfy the filter value and client IDs match + const rows = findAll(GENERAL.tableRow()); + rows.forEach((_, idx) => { + assert.dom(GENERAL.tableData(idx, filterKey)).hasText(filterValue); + const clientId = find(GENERAL.tableData(idx, 'client_id')).innerText; + // Make sure the rendered client id exists in the expected data + const isValid = expectedData.find((d) => d.client_id === clientId); + assert.true(!!isValid, `client_id: ${clientId} exists in expected dataset`); + }); + } + }; + }); + + test('it renders export data by client type in tabs organized by client type', async function (assert) { + await this.renderComponent(); + assert.dom(GENERAL.hdsTab('Entity')).hasAttribute('aria-selected', 'true', 'the first tab is selected'); + + for (const [tabName, clientType] of Object.entries(EXPORT_TAB_TO_TYPE)) { + const expectedData = this.expectedData(clientType); + await click(GENERAL.hdsTab(tabName)); + assert + .dom(GENERAL.hdsTab(tabName)) + .hasText(`${tabName} ${expectedData.length}`, `${tabName} tab counts match dataset length`); + assert + .dom(GENERAL.hdsTab(tabName)) + .hasAttribute('aria-selected', 'true', `it selects the tab: ${tabName}`); + + // Find all rendered rows and assert they match the client type tab + const rows = findAll(GENERAL.tableRow()); + rows.forEach((_, idx) => { + assert + .dom(GENERAL.tableData(idx, 'client_type')) + .hasText(clientType, `it renders ${clientType} data when ${tabName} is selected`); + }); + } + }); + + test('it renders expected columns for each client type', async function (assert) { + const expectedColumns = (isEntity = false) => { + const base = [ + { label: 'Client ID' }, + { label: 'Client type' }, + { label: 'Namespace path' }, + { label: 'Namespace ID' }, + { label: 'Initial usage More information for' }, // renders a tooltip which is why "More information for" is included + { label: 'Mount path' }, + { label: 'Mount type' }, + { label: 'Mount accessor' }, + ]; + const entityOnly = [ + { label: 'Entity name More information for' }, // renders a tooltip which is why "More information for" is included + { label: 'Entity alias name' }, + { label: 'Local entity alias' }, + { label: 'Policies' }, + { label: 'Entity metadata' }, + { label: 'Entity alias metadata' }, + { label: 'Entity alias custom metadata' }, + { label: 'Entity group IDs' }, + ]; + return isEntity ? [...base, ...entityOnly] : base; + }; + await this.renderComponent(); + + for (const tabName of Object.keys(EXPORT_TAB_TO_TYPE)) { + await click(GENERAL.hdsTab(tabName)); + expectedColumns(tabName === 'Entity').forEach((col, idx) => { + assert + .dom(GENERAL.tableColumnHeader(idx + 1, { isAdvanced: true })) + .hasText(col.label, `${tabName} renders ${col.label} column`); + }); + } + }); + + test('it renders dropdown lists from activity response to filter table data', async function (assert) { + await this.renderComponent(); + // Select each filter + await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)); + findAll(`${FILTERS.dropdown(ClientFilters.NAMESPACE)} li button`).forEach((item, idx) => { + const expected = this.expectedNamespaces[idx] === '' ? 'root' : this.expectedNamespaces[idx]; + assert.dom(item).hasText(expected, `namespace dropdown renders: ${expected}`); + }); + + await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH)); + findAll(`${FILTERS.dropdown(ClientFilters.MOUNT_PATH)} li button`).forEach((item, idx) => { + const expected = this.expectedMountPaths[idx]; + assert.dom(item).hasText(expected, `mount_path dropdown renders: ${expected}`); + }); + + await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)); + findAll(`${FILTERS.dropdown(ClientFilters.MOUNT_TYPE)} li button`).forEach((item, idx) => { + const expected = this.expectedMountTypes[idx]; + assert.dom(item).hasText(expected, `mount_type dropdown renders: ${expected}`); + }); + }); + + test('it fires @onFilterChange when filters are selected', async function (assert) { + const ns = 'root'; + const { mount_path, mount_type } = this.exportData[0]; + await this.renderComponent(); + + await click(FILTERS.dropdownToggle(ClientFilters.NAMESPACE)); + await click(FILTERS.dropdownItem(ns)); + // select mount path + await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_PATH)); + await click(FILTERS.dropdownItem(mount_path)); + // select mount type + await click(FILTERS.dropdownToggle(ClientFilters.MOUNT_TYPE)); + await click(FILTERS.dropdownItem(mount_type)); + + const [actual] = this.onFilterChange.lastCall.args; + assert.strictEqual(actual.namespace_path, ns, `@onFilterChange called with: ${ns}`); + assert.strictEqual(actual.mount_path, mount_path, `@onFilterChange called with: ${mount_path}`); + assert.strictEqual(actual.mount_type, mount_type, `@onFilterChange called with: ${mount_type}`); + }); + + // *FILTERING TESTS + test('it filters data if @filterQueryParams specify a namespace_path', async function (assert) { + const filterKey = 'namespace_path'; + const filterValue = 'ns2/'; + this.filterQueryParams[filterKey] = filterValue; + await this.renderComponent(); + await this.assertTabData(assert, filterKey, filterValue); + }); + + test('it filters data if @filterQueryParams specify a mount_path', async function (assert) { + const filterKey = 'mount_path'; + const filterValue = 'auth/token/'; + this.filterQueryParams[filterKey] = filterValue; + await this.renderComponent(); + await this.assertTabData(assert, filterKey, filterValue); + }); + + test('it filters data if @filterQueryParams specify a mount_type', async function (assert) { + const filterKey = 'mount_type'; + const filterValue = 'auth/ns_token/'; + this.filterQueryParams[filterKey] = filterValue; + await this.renderComponent(); + await this.assertTabData(assert, filterKey, filterValue); + }); + + test('it filters data if @filterQueryParams specify a multiple filters', async function (assert) { + this.filterQueryParams = { namespace_path: 'ns5/', mount_path: 'auth/token/', mount_type: 'ns_token' }; + const { namespace_path, mount_path, mount_type } = this.filterQueryParams; + await this.renderComponent(); + + for (const [tabName, clientType] of Object.entries(EXPORT_TAB_TO_TYPE)) { + const expectedData = this.expectedData(clientType).filter( + (d) => + d.namespace_path == namespace_path && d.mount_path === mount_path && d.mount_type === mount_type + ); + const length = expectedData.length; + await click(GENERAL.hdsTab(tabName)); + assert + .dom(GENERAL.hdsTab(tabName)) + .hasText(`${tabName} ${length}`, `${tabName} tab counts match dataset length`); + const noun = length === 1 ? 'client' : 'clients'; + const verb = length === 1 ? 'matches' : 'match'; + assert + .dom(CLIENT_COUNT.tableSummary(tabName)) + .hasText(`Summary: ${length} ${noun} ${verb} the filter criteria.`); + assert + .dom(GENERAL.hdsTab(tabName)) + .hasAttribute('aria-selected', 'true', `it selects the tab: ${tabName}`); + assert.dom(GENERAL.tableRow()).exists({ count: length }); + + // Find all rendered rows and assert they satisfy the filter value and client IDs match + const rows = findAll(GENERAL.tableRow()); + rows.forEach((_, idx) => { + assert.dom(GENERAL.tableData(idx, 'namespace_path')).hasText('ns5/'); + assert.dom(GENERAL.tableData(idx, 'mount_path')).hasText('auth/token/'); + assert.dom(GENERAL.tableData(idx, 'mount_type')).hasText('ns_token'); + // client_id is the unique identifier for each row + const clientId = find(GENERAL.tableData(idx, 'client_id')).innerText; + // Make sure the rendered client id exists in the expected data + const isValid = expectedData.find((d) => d.client_id === clientId); + assert.true(!!isValid, `client_id: ${clientId} exists in expected dataset`); + }); + } + }); + + test('it renders empty state message when filter selections yield no results', async function (assert) { + this.filterQueryParams = { namespace_path: 'dev/', mount_path: 'pluto/', mount_type: 'banana' }; + await this.renderComponent(); + + for (const tabName of Object.keys(EXPORT_TAB_TO_TYPE)) { + await click(GENERAL.hdsTab(tabName)); + assert + .dom(CLIENT_COUNT.card('table empty state')) + .hasText('No data found Clear or change filters to view client count data.'); + } + }); +}); diff --git a/ui/tests/integration/components/clients/page/overview-test.js b/ui/tests/integration/components/clients/page/overview-test.js index 4fe530674a..bf2c752062 100644 --- a/ui/tests/integration/components/clients/page/overview-test.js +++ b/ui/tests/integration/components/clients/page/overview-test.js @@ -5,7 +5,7 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'vault/tests/helpers'; -import { click, fillIn, findAll, render, triggerEvent } from '@ember/test-helpers'; +import { click, fillIn, find, findAll, render, triggerEvent } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { ACTIVITY_RESPONSE_STUB } from 'vault/tests/helpers/clients/client-count-helpers'; @@ -38,6 +38,27 @@ module('Integration | Component | clients/page/overview', function (hooks) { @onFilterChange={{this.onFilterChange}} @filterQueryParams={{this.filterQueryParams}} />`); + + this.assertTableData = async (assert, filterKey, filterValue) => { + const expectedData = flattenMounts(this.mostRecentMonth.new_clients.namespaces).filter( + (d) => d[filterKey] === filterValue + ); + await fillIn(GENERAL.selectByAttr('attribution-month'), this.mostRecentMonth.timestamp); + assert.dom(GENERAL.tableRow()).exists({ count: expectedData.length }); + // Find all rendered rows and assert they satisfy the filter value and table data matches expected values + const rows = findAll(GENERAL.tableRow()); + rows.forEach((_, idx) => { + assert.dom(GENERAL.tableData(idx, filterKey)).hasText(filterValue); + // Get namespace and mount paths to find original data in expectedData + const rowMountPath = find(GENERAL.tableData(idx, 'mount_path')).innerText; + const rowNsPath = find(GENERAL.tableData(idx, 'namespace_path')).innerText; + // find the expected clients from the response and assert the table matches + const { clients: expectedClients } = expectedData.find( + (d) => d.mount_path === rowMountPath && d.namespace_path === rowNsPath + ); + assert.dom(GENERAL.tableData(idx, 'clients')).hasText(`${expectedClients}`); + }); + }; }); test('it hides attribution when there is no data', async function (assert) { @@ -182,4 +203,56 @@ module('Integration | Component | clients/page/overview', function (hooks) { assert.dom(GENERAL.tableRow()).exists({ count: 5 }, '5 rows render'); assert.dom(GENERAL.paginationSizeSelector).hasValue('5', 'size selector does not reset to 10'); }); + + test('it filters data if @filterQueryParams specify a namespace_path', async function (assert) { + const filterKey = 'namespace_path'; + const filterValue = 'ns1'; + this.filterQueryParams[filterKey] = filterValue; + await this.renderComponent(); + await this.assertTableData(assert, filterKey, filterValue); + }); + + test('it filters data if @filterQueryParams specify a mount_path', async function (assert) { + const filterKey = 'mount_path'; + const filterValue = 'acme/pki/0'; + this.filterQueryParams[filterKey] = filterValue; + await this.renderComponent(); + await this.assertTableData(assert, filterKey, filterValue); + }); + + test('it filters data if @filterQueryParams specify a mount_type', async function (assert) { + const filterKey = 'mount_type'; + const filterValue = 'kv'; + this.filterQueryParams[filterKey] = filterValue; + await this.renderComponent(); + await this.assertTableData(assert, filterKey, filterValue); + }); + + test('it filters data if @filterQueryParams specify a multiple filters', async function (assert) { + this.filterQueryParams = { + namespace_path: 'ns1', + mount_path: 'auth/userpass/0', + mount_type: 'userpass', + }; + + const { namespace_path, mount_path, mount_type } = this.filterQueryParams; + await this.renderComponent(); + await fillIn(GENERAL.selectByAttr('attribution-month'), this.mostRecentMonth.timestamp); + const expectedData = flattenMounts(this.mostRecentMonth.new_clients.namespaces).find( + (d) => d.namespace_path === namespace_path && d.mount_path === mount_path && d.mount_type === mount_type + ); + assert.dom(GENERAL.tableRow()).exists({ count: 1 }); + assert.dom(GENERAL.tableData(0, 'namespace_path')).hasText(expectedData.namespace_path); + assert.dom(GENERAL.tableData(0, 'mount_path')).hasText(expectedData.mount_path); + assert.dom(GENERAL.tableData(0, 'mount_type')).hasText(expectedData.mount_type); + assert.dom(GENERAL.tableData(0, 'clients')).hasText(`${expectedData.clients}`); + }); + + test('it renders empty state message when filter selections yield no results', async function (assert) { + this.filterQueryParams = { namespace_path: 'dev/', mount_path: 'pluto/', mount_type: 'banana' }; + await this.renderComponent(); + assert + .dom(CLIENT_COUNT.card('table empty state')) + .hasText('No data found Clear or change filters to view client count data. Client count documentation'); + }); }); diff --git a/ui/tests/integration/components/clients/running-total-test.js b/ui/tests/integration/components/clients/running-total-test.js index 4fbdacf6d8..554a0a02d2 100644 --- a/ui/tests/integration/components/clients/running-total-test.js +++ b/ui/tests/integration/components/clients/running-total-test.js @@ -14,7 +14,6 @@ import { getUnixTime } from 'date-fns'; import { findAll } from '@ember/test-helpers'; import { formatNumber } from 'core/helpers/format-number'; import timestamp from 'core/utils/timestamp'; -import { setRunOptions } from 'ember-a11y-testing/test-support'; import { CLIENT_COUNT, CHARTS } from 'vault/tests/helpers/clients/client-count-selectors'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { parseAPITimestamp } from 'core/utils/date-formatters'; @@ -46,12 +45,6 @@ module('Integration | Component | clients/running-total', function (hooks) { /> `); }; - // Fails on #ember-testing-container - setRunOptions({ - rules: { - 'scrollable-region-focusable': { enabled: false }, - }, - }); }); test('it renders with full monthly activity data', async function (assert) { diff --git a/ui/tests/integration/utils/client-count-utils-test.js b/ui/tests/integration/utils/client-count-utils-test.js index a898ce74d0..8a3174b66d 100644 --- a/ui/tests/integration/utils/client-count-utils-test.js +++ b/ui/tests/integration/utils/client-count-utils-test.js @@ -20,6 +20,7 @@ import { ACTIVITY_RESPONSE_STUB as RESPONSE, MIXED_ACTIVITY_RESPONSE_STUB as MIXED_RESPONSE, SERIALIZED_ACTIVITY_RESPONSE, + ENTITY_EXPORT, } from 'vault/tests/helpers/clients/client-count-helpers'; /* @@ -463,5 +464,153 @@ module('Integration | Util | client count utils', function (hooks) { assert.propEqual(noMatches, [], 'returns an empty array when no keys match dataset'); this.assertOriginal(assert); }); + + test('it matches on empty strings or "root" for the root namespace', async function (assert) { + const mockExportData = ENTITY_EXPORT.trim() + .split('\n') + .map((line) => JSON.parse(line)); + const combinedData = [...this.mockMountData, ...mockExportData]; + const filteredData = filterTableData(combinedData, { namespace_path: 'root' }); + const expected = [ + { + acme_clients: 0, + clients: 8091, + entity_clients: 4002, + label: 'auth/userpass/0', + mount_path: 'auth/userpass/0', + mount_type: 'userpass', + namespace_path: 'root', + non_entity_clients: 4089, + secret_syncs: 0, + }, + { + acme_clients: 0, + clients: 4290, + entity_clients: 0, + label: 'secrets/kv/0', + mount_path: 'secrets/kv/0', + mount_type: 'kv', + namespace_path: 'root', + non_entity_clients: 0, + secret_syncs: 4290, + }, + { + acme_clients: 4003, + clients: 4003, + entity_clients: 0, + label: 'acme/pki/0', + mount_path: 'acme/pki/0', + mount_type: 'pki', + namespace_path: 'root', + non_entity_clients: 0, + secret_syncs: 0, + }, + { + client_first_used_time: '2025-08-15T23:48:09Z', + client_id: '5692c6ef-c871-128e-fb06-df2be7bfc0db', + client_type: 'entity', + entity_alias_custom_metadata: {}, + entity_alias_metadata: {}, + entity_alias_name: 'bob', + entity_group_ids: ['7537e6b7-3b06-65c2-1fb2-c83116eb5e6f'], + entity_metadata: {}, + entity_name: 'entity_b3e2a7ff', + local_entity_alias: false, + mount_accessor: 'auth_userpass_f47ad0b4', + mount_path: 'auth/userpass/', + mount_type: 'userpass', + namespace_id: 'root', + namespace_path: '', + policies: [], + token_creation_time: '2025-08-15T23:48:09Z', + }, + { + client_first_used_time: '2025-08-15T23:53:19Z', + client_id: '23a04911-5d72-ba98-11d3-527f2fcf3a81', + client_type: 'entity', + entity_alias_custom_metadata: { + account: 'Tester Account', + }, + entity_alias_metadata: {}, + entity_alias_name: 'bob', + entity_group_ids: ['7537e6b7-3b06-65c2-1fb2-c83116eb5e6f'], + entity_metadata: { + organization: 'ACME Inc.', + team: 'QA', + }, + entity_name: 'bob-smith', + local_entity_alias: false, + mount_accessor: 'auth_userpass_de28062c', + mount_path: 'auth/userpass-test/', + mount_type: 'userpass', + namespace_id: 'root', + namespace_path: '', + policies: ['base'], + token_creation_time: '2025-08-15T23:52:38Z', + }, + { + client_first_used_time: '2025-08-16T09:16:03Z', + client_id: 'a7c8d912-4f61-23b5-88e4-627a3dcf2b92', + client_type: 'entity', + entity_alias_custom_metadata: { + role: 'Senior Engineer', + }, + entity_alias_metadata: { + department: 'Engineering', + }, + entity_alias_name: 'alice', + entity_group_ids: ['7537e6b7-3b06-65c2-1fb2-c83116eb5e6f', 'a1b2c3d4-5e6f-7g8h-9i0j-k1l2m3n4o5p6'], + entity_metadata: { + location: 'San Francisco', + organization: 'TechCorp', + team: 'DevOps', + }, + entity_name: 'alice-johnson', + local_entity_alias: false, + mount_accessor: 'auth_userpass_f47ad0b4', + mount_path: 'auth/userpass/', + mount_type: 'userpass', + namespace_id: 'root', + namespace_path: '', + policies: ['admin', 'audit'], + token_creation_time: '2025-08-16T09:15:42Z', + }, + { + client_first_used_time: '2025-08-17T16:44:12Z', + client_id: 'c6b9d248-5a71-39e4-c7f2-951d8eaf6b95', + client_type: 'entity', + entity_alias_custom_metadata: { + expertise: 'kubernetes', + on_call: 'true', + }, + entity_alias_metadata: { + iss: 'https://auth.cloudops.io', + sub: 'frank.castle@cloudops.io', + }, + entity_alias_name: 'frank', + entity_group_ids: ['9a8b7c6d-5e4f-3210-9876-543210fedcba'], + entity_metadata: { + organization: 'CloudOps', + region: 'us-east-1', + team: 'SRE', + }, + entity_name: 'frank-castle', + local_entity_alias: false, + mount_accessor: 'auth_jwt_9d8c7b6a', + mount_path: 'auth/jwt/', + mount_type: 'jwt', + namespace_id: 'root', + namespace_path: '', + policies: ['operations', 'monitoring'], + token_creation_time: '2025-08-17T16:43:28Z', + }, + ]; + assert.propEqual( + filteredData, + expected, + "filtered data includes items with namespace_path equal to either 'root' or an empty string" + ); + this.assertOriginal(assert); + }); }); });