Vault Automation 5d869440c3
[UI] Ember Data Migration - Client Counts (#12026) (#12132)
* updates flags service to use api service

* converts clients index route to ts

* updates clients config workflows to use api service

* updates clients date-range component to handle Date objects rather than ISO strings

* updates clients page-header component to handle Date objects and use api and capabilities services

* updates clients route to use api and capabilities services

* updates types in client-counts helpers

* updates client counts route to use api service

* updates types for client-counts serializers

* updates date handling in client counts page component

* updates clients overview page component

* converts clients page-header component to ts

* fixes type errors in clients page-header component

* updates client counts tests

* updates client-count-card component to use api service

* converts client-count-card component to ts

* removes model-form-fields test that uses clients/config model

* removes clients/version-history model usage from client-counts helpers tests

* removes migrated models from adapter and model registries

* removes clients ember data models, adapters and serializers

* updates clients date-range component to format dates in time zone

* cleans up references to activityError in client counts route

* adds clients/activity mirage model

* updates activation flags assertions in sync overview tests

* fixes issue selecting current period in clients date-range component and adds test

* fixes issues with enabled state for client counts

* updates parseAPITimestamp to handle date object formatting

* removes unnecesarry type casting for format return in parseAPITimestamp util

* updates parseAPITimestamp to use formatInTimeZone for strings

* updates parseAPITimestamp comment

* updates enabled value in clients config component to boolean

* adds date-fns-tz to core addon

* removes parseISO from date-formatters util in favor of new Date

* updates comments for client counts

* updates retention months validation for client counts config

* updates comment and min retention months default for client counts config

Co-authored-by: Jordan Reimer <zofskeez@gmail.com>
2026-02-03 16:18:52 +00:00

385 lines
14 KiB
JavaScript

/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { filterVersionHistory, flattenMounts, filterTableData } from 'core/utils/client-counts/helpers';
import clientsHandler from 'vault/mirage/handlers/clients';
import {
SERIALIZED_ACTIVITY_RESPONSE,
ENTITY_EXPORT,
} from 'vault/tests/helpers/clients/client-count-helpers';
module('Unit | Util | client counts | helpers', function (hooks) {
setupTest(hooks);
module('filterVersionHistory', function (hooks) {
setupMirage(hooks);
hooks.beforeEach(async function () {
clientsHandler(this.server);
const api = this.owner.lookup('service:api');
// format returned by model hook in routes/vault/cluster/clients.ts
const response = await api.sys.versionHistory(true);
this.versionHistory = api
.keyInfoToArray(response, 'version')
.map(({ version, previous_version, timestamp_installed }) => ({
// order of keys needs to match expected order
previous_version,
timestamp_installed,
version,
}));
});
test('it returns version data for upgrade to notable versions: 1.9, 1.10, 1.17', async function (assert) {
assert.expect(3);
const original = [...this.versionHistory];
const expected = [
{
previous_version: '1.9.0',
timestamp_installed: '2023-08-02T00:00:00Z',
version: '1.9.1',
},
{
previous_version: '1.9.1',
timestamp_installed: '2023-09-02T00:00:00Z',
version: '1.10.1',
},
{
previous_version: '1.16.0',
timestamp_installed: '2023-12-02T00:00:00Z',
version: '1.17.0',
},
];
// set start/end times longer than version history to test all relevant upgrades return
const startTime = new Date('2023-06-02T00:00:00Z'); // first upgrade installed '2023-07-02T00:00:00Z'
const endTime = new Date('2024-03-04T16:14:21Z'); // latest upgrade installed '2023-12-02T00:00:00Z'
const filteredHistory = filterVersionHistory(this.versionHistory, startTime, endTime);
assert.deepEqual(
JSON.stringify(filteredHistory),
JSON.stringify(expected),
'it returns all notable upgrades'
);
assert.notPropContains(
filteredHistory,
{
version: '1.9.0',
previous_version: null,
timestamp_installed: '2023-07-02T00:00:00Z',
},
'does not include version history if previous_version is null'
);
assert.propEqual(this.versionHistory, original, 'it does not modify original array');
});
test('it only returns version data for initial upgrades between given date range', async function (assert) {
assert.expect(2);
const expected = [
{
previous_version: '1.9.0',
timestamp_installed: '2023-08-02T00:00:00Z',
version: '1.9.1',
},
{
previous_version: '1.9.1',
timestamp_installed: '2023-09-02T00:00:00Z',
version: '1.10.1',
},
];
const startTime = new Date('2023-08-02T00:00:00Z'); // same date as 1.9.1 install date to catch same day edge cases
const endTime = new Date('2023-11-02T00:00:00Z');
const filteredHistory = filterVersionHistory(this.versionHistory, startTime, endTime);
assert.deepEqual(
JSON.stringify(filteredHistory),
JSON.stringify(expected),
'it only returns upgrades during date range'
);
assert.notPropContains(
filteredHistory,
{
version: '1.10.3',
previous_version: '1.10.1',
timestamp_installed: '2023-09-23T00:00:00Z',
},
'it does not return subsequent patch versions of the same notable upgrade version'
);
});
});
test('flattenMounts: it flattens mount data', async function (assert) {
assert.expect(2);
const original = [...SERIALIZED_ACTIVITY_RESPONSE.by_namespace];
const expected = [
...SERIALIZED_ACTIVITY_RESPONSE.by_namespace[0].mounts,
...SERIALIZED_ACTIVITY_RESPONSE.by_namespace[1].mounts,
];
const actual = flattenMounts(SERIALIZED_ACTIVITY_RESPONSE.by_namespace);
assert.propEqual(actual, expected, 'it returns mounts from each namespace object into a single array');
assert.propEqual(
SERIALIZED_ACTIVITY_RESPONSE.by_namespace,
original,
'it does not modify original by_namespace array'
);
});
module('filterTableData', function (hooks) {
hooks.beforeEach(async function () {
const activityByMount = flattenMounts(SERIALIZED_ACTIVITY_RESPONSE.by_namespace);
this.mockMountData = [...activityByMount];
// copy mock data before using the filterTableData function to assert filtering doesn't modify the original array
const original = [...this.mockMountData];
this.assertOriginal = (assert) =>
assert.propEqual(this.mockMountData, original, 'filtering does not mutate dataset');
});
test('it returns original data if no filters are passed', async function (assert) {
const emptyObject = filterTableData(this.mockMountData, {});
assert.propEqual(emptyObject, this.mockMountData, 'when filters arg is an empty object');
this.assertOriginal(assert);
const emptyValues = filterTableData(this.mockMountData, {
namespace_path: '',
mount_path: '',
mount_type: '',
});
assert.propEqual(emptyValues, this.mockMountData, 'when filters have are empty strings');
this.assertOriginal(assert);
const nullFilter = filterTableData(this.mockMountData, null);
assert.propEqual(nullFilter, this.mockMountData, 'returns all data when no filters are null');
this.assertOriginal(assert);
});
test('it filters data for a single filter', async function (assert) {
const namespaceFilter = filterTableData(this.mockMountData, {
namespace_path: 'root',
mount_path: '',
mount_type: '',
});
const expectedNamespaceFilter = this.mockMountData.filter((m) => m.namespace_path === 'root');
assert.propEqual(namespaceFilter, expectedNamespaceFilter, 'it filters by namespace_path');
this.assertOriginal(assert);
const mountPathFilter = filterTableData(this.mockMountData, {
namespace_path: '',
mount_path: 'acme/pki/0/',
mount_type: '',
});
const expectedMountPathFilter = this.mockMountData.filter((m) => m.mount_path === 'acme/pki/0/');
assert.propEqual(mountPathFilter, expectedMountPathFilter, 'it filters by mount_path');
this.assertOriginal(assert);
const mountTypeFilter = filterTableData(this.mockMountData, {
namespace_path: '',
mount_path: '',
mount_type: 'userpass',
});
const expectedMountTypeFilter = this.mockMountData.filter((m) => m.mount_type === 'userpass');
assert.propEqual(mountTypeFilter, expectedMountTypeFilter, 'it filters by mount_type');
this.assertOriginal(assert);
});
test('it filters data for a multiple filters', async function (assert) {
const twoFilters = filterTableData(this.mockMountData, {
namespace_path: 'root',
mount_path: '',
mount_type: 'userpass',
});
const expectedTwoFilters = this.mockMountData.filter(
(m) => m.namespace_path === 'root' && m.mount_type === 'userpass'
);
assert.propEqual(twoFilters, expectedTwoFilters, 'it filters by namespace_path and mount_type');
this.assertOriginal(assert);
const allFilters = filterTableData(this.mockMountData, {
namespace_path: 'root',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
});
const expectedAllFilters = [
{
label: 'auth/userpass/0/',
mount_path: 'auth/userpass/0/',
mount_type: 'userpass',
namespace_path: 'root',
acme_clients: 0,
clients: 8091,
entity_clients: 4002,
non_entity_clients: 4089,
secret_syncs: 0,
},
];
assert.propEqual(allFilters, expectedAllFilters, 'it filters by all filters');
this.assertOriginal(assert);
});
test('it returns an empty array when there are no matches', async function (assert) {
const noMatches = filterTableData(this.mockMountData, {
namespace_path: 'does not exist',
mount_path: '',
mount_type: '',
});
assert.propEqual(noMatches, [], 'returns an empty array when no data matches filters');
this.assertOriginal(assert);
});
test('it returns an empty array when filter includes keys the dataset does not contain', async function (assert) {
const noMatches = filterTableData(this.mockMountData, { foo: 'root', bar: '' });
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-07-15T23:48:09Z',
client_id: 'daf8420c-0b6b-34e6-ff38-ee1ed093bea9',
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: '2020-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: '2020-08-15T23:52:38Z',
},
{
client_first_used_time: '2025-09-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: '2020-08-16T09:15:42Z',
},
{
client_first_used_time: '2025-12-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: '2020-08-17T16:43:28Z',
},
];
filteredData.forEach((d, idx) => {
const identifier = idx < 3 ? `label: ${d.label}` : `client_id: ${d.client_id}`;
assert.propEqual(d, expected[idx], `filtered data contains ${identifier}`);
});
this.assertOriginal(assert);
});
});
});