[VAULT-35715] UI: Namespace picker is updated after add/delete namespace (#30737)

* [VAULT-35715] UI: Namespace picker is updated after add/delete namespace

* + changelog

* pairing session updates

* fixing tests (wip)

* fix tests!

* remove meep lol

* fix one more test

* add missing test coverage

* address PR comments, enterprise tests are passing again!!

* fix lint issue
This commit is contained in:
Shannon Roberts (Beagin) 2025-06-11 10:59:16 -07:00 committed by GitHub
parent 4c5876ec81
commit b54aba9fd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 529 additions and 273 deletions

3
changelog/30737.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:bug
ui: Automatically refresh namespace list inside the namespace picker after creating or deleting a namespace in the UI.
```

View file

@ -6,7 +6,13 @@
<div class="namespace-picker side-padding-4" ...attributes>
<Hds::Dropdown @enableCollisionDetection={{true}} as |D|>
<D.ToggleButton @icon="org" @text={{or this.selected.id "-"}} @isFullWidth={{true}} data-test-namespace-toggle />
<D.ToggleButton
@icon="org"
@text={{or this.selectedNamespace.id "-"}}
@isFullWidth={{true}}
data-test-toggle-input="namespace-id"
{{on "click" this.toggleNamespacePicker}}
/>
{{#if this.errorLoadingNamespaces}}
@ -52,7 +58,7 @@
<div class="is-overflow-y-auto is-max-drawer-height" {{did-insert this.setupScrollListener}}>
{{#each this.visibleNamespaceOptions as |option|}}
<D.Checkmark
@selected={{eq option.id this.selected.id}}
@selected={{eq option.id this.selectedNamespace.id}}
{{on "click" (fn this.onChange option)}}
data-test-namespace-link={{option.path}}
>

View file

@ -39,7 +39,6 @@ export default class NamespacePicker extends Component {
// Load 200 namespaces in the namespace picker at a time
@tracked batchSize = 200;
@tracked allNamespaces: NamespaceOption[] = [];
@tracked canManageNamespaces = false; // Show/hide manage namespaces button
@tracked canRefreshNamespaces = false; // Show/hide refresh list button
@tracked errorLoadingNamespaces = '';
@ -47,13 +46,24 @@ export default class NamespacePicker extends Component {
@tracked searchInput = '';
@tracked searchInputHelpText =
"Enter a full path in the search bar and hit the 'Enter' ↵ key to navigate faster.";
@tracked selected: NamespaceOption | null = null;
constructor(owner: unknown, args: Record<string, never>) {
super(owner, args);
this.loadOptions();
}
get allNamespaces(): NamespaceOption[] {
return this.getOptions(
this.namespace?.accessibleNamespaces,
this.namespace?.currentNamespace,
this.namespace?.path
);
}
get selectedNamespace(): NamespaceOption | null {
return this.getSelected(this.allNamespaces, this.namespace?.path) ?? null;
}
private matchesPath(option: NamespaceOption, currentPath: string): boolean {
return option?.path === currentPath;
}
@ -62,7 +72,11 @@ export default class NamespacePicker extends Component {
return options.find((option) => this.matchesPath(option, currentPath));
}
private getOptions(namespace: NamespaceService): NamespaceOption[] {
private getOptions(
accessibleNamespaces: string[],
currentNamespace: string,
path: string
): NamespaceOption[] {
/* Each namespace option has 3 properties: { id, path, and label }
* - id: node / namespace name (displayed when the namespace picker is closed)
* - path: full namespace path (used to navigate to the namespace)
@ -76,7 +90,7 @@ export default class NamespacePicker extends Component {
* | 'child' | 'parent/child' | 'parent/child' |
*/
const options = [
...(namespace?.accessibleNamespaces || []).map((ns: string) => {
...(accessibleNamespaces || []).map((ns: string) => {
const parts = ns.split('/');
return { id: parts[parts.length - 1] || '', path: ns, label: ns };
}),
@ -91,9 +105,9 @@ export default class NamespacePicker extends Component {
// to the list of options. This is a fallback for when the user has access to a single namespace.
if (options.length === 0) {
options.push({
id: namespace.currentNamespace,
path: namespace.path,
label: namespace.path,
id: currentNamespace,
path: path,
label: path,
});
}
@ -187,9 +201,6 @@ export default class NamespacePicker extends Component {
this.errorLoadingNamespaces = errorMessage(error);
}
this.allNamespaces = this.getOptions(this.namespace);
this.selected = this.getSelected(this.allNamespaces, this.namespace?.path) ?? null;
await this.fetchListCapability();
}
@ -216,7 +227,6 @@ export default class NamespacePicker extends Component {
@action
async onChange(selected: NamespaceOption): Promise<void> {
this.selected = selected;
this.searchInput = '';
this.router.transitionTo('vault.cluster.dashboard', { queryParams: { namespace: selected.path } });
}
@ -227,7 +237,6 @@ export default class NamespacePicker extends Component {
const matchingNamespace = this.allNamespaces.find((ns) => ns.label === this.searchInput.trim());
if (matchingNamespace) {
this.selected = matchingNamespace;
this.searchInput = '';
this.router.transitionTo('vault.cluster.dashboard', {
queryParams: { namespace: matchingNamespace.path },
@ -247,4 +256,10 @@ export default class NamespacePicker extends Component {
this.searchInput = '';
await this.loadOptions();
}
@action
toggleNamespacePicker() {
// Reset the search input when the dropdown is toggled
this.searchInput = '';
}
}

View file

@ -75,7 +75,7 @@
<Toggle
@checked={{this.clusterRoleBinding}}
@onChange={{(toggle-action "clusterRoleBinding" this)}}
data-test-kubernetes-clusterRoleBinding
data-test-toggle-input="kubernetes-clusterRoleBinding"
>
<h3 class="title is-7 is-marginless">ClusterRoleBinding</h3>
<div class="description has-text-grey">

View file

@ -94,12 +94,4 @@ export default function (server) {
},
};
});
server.get('sys/internal/ui/namespaces', function () {
return {
data: {
keys: ['ns1/', 'ns2/', 'ns3/', 'ns4/', 'ns5/', 'ns6/', 'ns7/', 'ns8/', 'ns9/', 'ns10/'],
},
};
});
}

View file

@ -3,27 +3,27 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { currentRouteName, visit, click, fillIn, currentURL } from '@ember/test-helpers';
import { currentRouteName, visit, click, fillIn, currentURL, findAll } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { createNSFromPaths, deleteNSFromPaths } from 'vault/tests/helpers/commands';
import { NAMESPACE_PICKER_SELECTORS } from 'vault/tests/helpers/namespace-picker';
module('Acceptance | Enterprise | /access/namespaces', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
const searchInput = GENERAL.filterInputExplicit;
const searchButton = GENERAL.filterInputExplicitSearch;
hooks.beforeEach(function () {
return login();
hooks.beforeEach(async () => {
await login();
});
test('it navigates to namespaces page', async function (assert) {
assert.expect(1);
// Go to the manage namespaces page
await visit('/vault/access/namespaces');
assert.strictEqual(
currentRouteName(),
'vault.cluster.access.namespaces.index',
@ -32,52 +32,159 @@ module('Acceptance | Enterprise | /access/namespaces', function (hooks) {
});
test('it displays the breadcrumb trail', async function (assert) {
// Go to the manage namespaces page
await visit('/vault/access/namespaces');
assert.dom(GENERAL.breadcrumb).exists({ count: 1 }, 'Only one breadcrumb is displayed');
assert.dom(GENERAL.breadcrumb).hasText('Namespaces', 'Breadcrumb trail is displayed correctly');
});
test('it should render correct number of namespaces', async function (assert) {
// Setup: Create namespace(s) via the CLI
const namespaces = [
'ns1',
'ns2',
'ns3',
'ns4',
'ns5',
'ns6',
'ns7',
'ns8',
'ns9',
'ns10',
'ns11',
'ns12',
'ns13',
'ns14',
'ns15',
'ns16',
'ns17',
'ns18',
];
await createNSFromPaths(namespaces);
assert.expect(3);
// Go to the manage namespaces page
await visit('/vault/access/namespaces');
const store = this.owner.lookup('service:store');
// Default page size is 15
assert.strictEqual(store.peekAll('namespace').length, 15, 'Store has 15 namespaces records');
assert.dom('.list-item-row').exists({ count: 15 }, 'Should display 15 namespaces');
assert.dom('.hds-pagination').exists();
// Cleanup: Delete namespace(s) via the CLI
await deleteNSFromPaths(namespaces);
});
test('it should show button to refresh namespace list', async function (assert) {
let refreshNetworkRequestTriggered;
const refreshNamespaceButton = GENERAL.button('refresh-namespace-list');
const testNS = 'test-refresh-ns';
this.server.get('/sys/internal/ui/namespaces', () => {
refreshNetworkRequestTriggered = true;
return;
});
// Setup: Create namespace via the CLI
const namespaces = [testNS];
await createNSFromPaths(namespaces);
// Go to the manage namespaces page
await visit('/vault/access/namespaces');
assert.dom(refreshNamespaceButton).hasText('Refresh list', 'Refresh button is rendered correctly');
// Open the namespace picker
await click(GENERAL.toggleInput('namespace-id'));
refreshNetworkRequestTriggered = false;
await click(refreshNamespaceButton);
assert.true(
refreshNetworkRequestTriggered,
'Get namespaces network request was made when refresh button was clicked'
// Verify the search input field exists
assert.dom(NAMESPACE_PICKER_SELECTORS.searchInput).exists('The namespace search field exists');
// Verify 0 namespaces are displayed after searching for "test-refresh-ns"
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, testNS);
assert.strictEqual(
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
0,
`No namespaces are displayed after searching for "${testNS}"`
);
// Close the namespace picker
await click(GENERAL.toggleInput('namespace-id'));
// Click the refresh list button
assert
.dom(GENERAL.button('refresh-namespace-list'))
.hasText('Refresh list', 'Refresh button is rendered correctly');
await click(GENERAL.button('refresh-namespace-list'));
// Open the namespace picker
await click(GENERAL.toggleInput('namespace-id'));
// Verify the search input field exists
assert.dom(NAMESPACE_PICKER_SELECTORS.searchInput).exists('The namespace search field exists');
// Verify 1 namespace is displayed after searching for "test-refresh-ns"
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, testNS);
assert.strictEqual(
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
1,
`1 namespace is displayed after searching for "${testNS}"`
);
// Close the namespace picker
await click(GENERAL.toggleInput('namespace-id'));
// Cleanup: Delete namespace via the CLI
await deleteNSFromPaths(namespaces);
// Go to the manage namespaces page
await visit('/vault/access/namespaces');
// Open the namespace picker
await click(GENERAL.toggleInput('namespace-id'));
// Verify the search input field exists
assert.dom(NAMESPACE_PICKER_SELECTORS.searchInput).exists('The namespace search field exists');
// Verify 1 namespace is displayed after searching for "test-refresh-ns"
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, testNS);
assert.strictEqual(
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
1,
`1 namespace is displayed after searching for "${testNS}"`
);
// Close the namespace picker
await click(GENERAL.toggleInput('namespace-id'));
// Click the refresh list button
assert
.dom(GENERAL.button('refresh-namespace-list'))
.hasText('Refresh list', 'Refresh button is rendered correctly');
await click(GENERAL.button('refresh-namespace-list'));
// Open the namespace picker
await click(GENERAL.toggleInput('namespace-id'));
// Verify the search input field exists
assert.dom(NAMESPACE_PICKER_SELECTORS.searchInput).exists('The namespace search field exists');
// Verify 0 namespaces are displayed after searching for "test-refresh-ns"
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, testNS);
assert.strictEqual(
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
0,
`No namespaces are displayed after searching for "${testNS}"`
);
// Close the namespace picker
await click(GENERAL.toggleInput('namespace-id'));
});
test('it should show button to create new namespace', async function (assert) {
const createNamespaceLink = GENERAL.linkTo('create-namespace');
// Go to the manage namespaces page
await visit('/vault/access/namespaces');
assert
.dom(createNamespaceLink)
.dom(GENERAL.linkTo('create-namespace'))
.hasText('Create namespace', 'Create namespace button is rendered correctly');
assert
.dom(createNamespaceLink)
.dom(GENERAL.linkTo('create-namespace'))
.hasAttribute(
'href',
'/ui/vault/access/namespaces/create',
@ -85,44 +192,143 @@ module('Acceptance | Enterprise | /access/namespaces', function (hooks) {
);
});
test('it should update namespace list after create/delete without manual refresh', async function (assert) {
const testNS = 'test-create-ns';
// Go to the manage namespaces page
await visit('/vault/access/namespaces');
// Verify test-create-ns does not exist in the Manage Namespace page
await fillIn(GENERAL.filterInputExplicit, testNS);
await click(GENERAL.filterInputExplicitSearch);
assert.dom('.list-item-row').exists({ count: 0 }, `"${testNS}" namespace is not displayed on the page`);
// Verify test-create-ns does not exist in the Namespace Picker
await click(GENERAL.toggleInput('namespace-id'));
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, testNS);
assert.strictEqual(
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
0,
`"${testNS}" is not displayed in the namespace picker`
);
await click(GENERAL.toggleInput('namespace-id'));
// Create a new namespace
assert
.dom(GENERAL.linkTo('create-namespace'))
.hasText('Create namespace', 'Create namespace button is displayed');
await click(GENERAL.linkTo('create-namespace'));
assert.dom(GENERAL.inputByAttr('path')).exists('Create namespace input field is displayed');
await fillIn(GENERAL.inputByAttr('path'), testNS);
assert.dom('[data-test-edit-form-submit]').exists('Save button is displayed');
await click('[data-test-edit-form-submit]');
// Verify test-create-ns does not exist in the Manage Namespace page
await fillIn(GENERAL.filterInputExplicit, testNS);
await click(GENERAL.filterInputExplicitSearch);
assert.dom('.list-item-row').exists({ count: 1 }, `"${testNS}" namespace is displayed on the page`);
// Verify test-create-ns exists in the Namespace Picker without refresh
await click(GENERAL.toggleInput('namespace-id'));
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, testNS);
assert.strictEqual(
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
1,
`"${testNS}" is displayed in the namespace picker`
);
await click(GENERAL.toggleInput('namespace-id'));
// Delete the created namespace
assert.dom(GENERAL.menuTrigger).exists('Namespace options menu is displayed');
await click(GENERAL.menuTrigger);
assert
.dom('.hds-dropdown-list-item:nth-of-type(2)')
.hasText('Delete', 'Delete namespace option is displayed');
await click('.hds-dropdown-list-item:nth-of-type(2) button');
assert.dom(GENERAL.confirmButton).hasText('Confirm', 'Confirm namespace deletion button is shown');
await click(GENERAL.confirmButton);
// Verify test-create-ns does not exist in the Manage Namespace page
await fillIn(GENERAL.filterInputExplicit, testNS);
await click(GENERAL.filterInputExplicitSearch);
assert.dom('.list-item-row').exists({ count: 0 }, `"${testNS}" namespace is not displayed on the page`);
// Verify test-create-ns does not exist in the Namespace Picker
await click(GENERAL.toggleInput('namespace-id'));
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, testNS);
assert.strictEqual(
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
0,
`"${testNS}" is not displayed in the namespace picker`
);
await click(GENERAL.toggleInput('namespace-id'));
});
test('it should filter namespaces based on search input', async function (assert) {
// Setup: Create namespace(s) via the CLI
const namespaces = ['parent', 'other-parent'];
await createNSFromPaths(namespaces);
// Go to the manage namespaces page
await visit('/vault/access/namespaces');
// Enter search text
await fillIn(searchInput, 'ns4');
assert.dom(searchInput).hasValue('ns4', 'Search input contains the entered text');
await fillIn(GENERAL.filterInputExplicit, 'other');
assert.dom(GENERAL.filterInputExplicit).hasValue('other', 'Search input contains the entered text');
// Click the search button
await click(searchButton);
await click(GENERAL.filterInputExplicitSearch);
// Verify the filtered results
assert.dom('.list-item-row').exists({ count: 1 }, 'Filtered results are displayed correctly');
assert.dom('.list-item-row').hasText('ns4', 'Correct namespace is displayed in the filtered results');
assert
.dom('.list-item-row')
.hasText('other-parent', 'Correct namespace is displayed in the filtered results');
// Verify the URL query param is updated
assert.strictEqual(
currentURL(),
'/vault/access/namespaces?page=1&pageFilter=ns4',
'/vault/access/namespaces?page=1&pageFilter=other',
'URL query param is updated to reflect the search field as pageFilter'
);
// Clear the search input
await fillIn(searchInput, '');
await click(searchButton);
assert.dom(searchInput).hasValue('', 'Search input is cleared');
await fillIn(GENERAL.filterInputExplicit, '');
await click(GENERAL.filterInputExplicitSearch);
assert.dom(GENERAL.filterInputExplicit).hasValue('', 'Search input is cleared');
assert
.dom('.list-item-row')
.exists({ count: 15 }, 'All namespaces are displayed after clearing the search input');
.exists({ count: 2 }, 'All namespaces are displayed after clearing the search input');
assert.strictEqual(
currentURL(),
'/vault/access/namespaces?page=1',
'URL query param is updated to remove pageFilter'
);
// Cleanup: Delete namespace(s) via the CLI
await deleteNSFromPaths(namespaces);
});
test('it should show options menu for each namespace', async function (assert) {
// Setup: Create namespace(s) via the CLI
const namespace = 'asdf';
await createNSFromPaths([namespace]);
// Go to the manage namespaces page
await visit('/vault/access/namespaces');
assert.dom(GENERAL.menuTrigger).exists();
// Hack: Trigger refresh internal namespaces endpoint
await click(GENERAL.toggleInput('namespace-id'));
await click(GENERAL.button('Refresh list'));
// Enter search text
await fillIn(GENERAL.filterInputExplicit, namespace);
await click(GENERAL.filterInputExplicitSearch);
await click(GENERAL.button('refresh-namespace-list'));
assert.dom(GENERAL.menuTrigger).exists('Namespace options menu is displayed');
await click(GENERAL.menuTrigger);
assert.dom('.hds-dropdown-list-item').exists({ count: 2 }, 'Should display 2 options in the menu.');
@ -135,30 +341,16 @@ module('Acceptance | Enterprise | /access/namespaces', function (hooks) {
.dom(`${switchNamespaceButton} a`)
.hasAttribute(
'href',
'http://localhost:7357/ui/vault/dashboard?namespace=ns1',
`http://localhost:7357/ui/vault/dashboard?namespace=${namespace}`,
'Switch namespace button has the correct href attribute'
);
// Verify that the user can delete the namespace
const deleteNamespaceButton = '.hds-dropdown-list-item:nth-of-type(2)';
assert.dom(deleteNamespaceButton).hasText('Delete', 'Allow users to delete the namespace');
});
assert
.dom('.hds-dropdown-list-item:nth-of-type(2)')
.hasText('Delete', 'Delete namespace option is displayed');
test('it should hide the switch to namespace option for unaccessible namespaces', async function (assert) {
await visit('/vault/access/namespaces');
// Search for a namespace that is not accessible
await fillIn(searchInput, 'ns12');
await click(searchButton);
assert.dom(GENERAL.menuTrigger).exists();
await click(GENERAL.menuTrigger);
// Verify that only the delete option is available for the unaccessible namespace
assert.dom('.hds-dropdown-list-item').exists({ count: 1 }, 'Should display 1 option in the menu.');
// Verify that the user can delete the namespace
const deleteNamespaceButton = '.hds-dropdown-list-item:nth-of-type(1)';
assert.dom(deleteNamespaceButton).hasText('Delete', 'Allow users to delete the namespace');
// Cleanup: Delete namespace(s) via the CLI
await deleteNSFromPaths([namespace]);
});
});

View file

@ -15,67 +15,21 @@ import {
} from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { runCmd, createNS, deleteNS } from 'vault/tests/helpers/commands';
import { runCmd, createNSFromPaths, deleteNSFromPaths } from 'vault/tests/helpers/commands';
import { login, loginNs, logout } from 'vault/tests/helpers/auth/auth-helpers';
import { AUTH_FORM } from 'vault/tests/helpers/auth/auth-form-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { NAMESPACE_PICKER_SELECTORS } from '../helpers/namespace-picker';
import sinon from 'sinon';
async function createNamespaces(namespaces) {
for (const ns of namespaces) {
// Note: iterate through the namespace parts to create the full namespace path
const parts = ns.split('/');
let currentPath = '';
for (const part of parts) {
// Visit the parent namespace
const url = `/vault/dashboard${currentPath && `?namespace=${currentPath.replaceAll('/', '%2F')}`}`;
await visit(url);
currentPath = currentPath ? `${currentPath}/${part}` : part;
// Create the current namespace
await runCmd(createNS(part), false);
await settled();
}
// Reset to the root namespace
const url = '/vault/dashboard';
await visit(url);
}
}
async function deleteNamespaces(namespaces) {
// Reset to the root namespace
const url = '/vault/dashboard';
await visit(url);
for (const ns of namespaces) {
// Note: delete the parent namespace to delete all child namespaces
const part = ns.split('/')[0];
await runCmd(deleteNS(part), false);
await settled();
}
}
module('Acceptance | Enterprise | namespaces', function (hooks) {
setupApplicationTest(hooks);
let fetchSpy;
hooks.beforeEach(() => {
fetchSpy = sinon.spy(window, 'fetch');
return login();
});
hooks.afterEach(() => {
fetchSpy.restore();
hooks.beforeEach(async () => {
await login();
});
test('it focuses the search input field when user toggles namespace picker', async function (assert) {
await click(NAMESPACE_PICKER_SELECTORS.toggle);
await click(GENERAL.toggleInput('namespace-id'));
// Verify that the search input field is focused
const searchInput = find(NAMESPACE_PICKER_SELECTORS.searchInput);
@ -87,11 +41,11 @@ module('Acceptance | Enterprise | namespaces', function (hooks) {
});
test('it navigates to the matching namespace when Enter is pressed', async function (assert) {
// Test Setup
// Setup: Create namespace(s) via the CLI
const namespaces = ['beep/boop'];
await createNamespaces(namespaces);
await createNSFromPaths(namespaces);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
await click(GENERAL.toggleInput('namespace-id'));
await click(GENERAL.button('Refresh list'));
assert.dom(NAMESPACE_PICKER_SELECTORS.searchInput).exists('The namespace search field exists');
@ -112,16 +66,16 @@ module('Acceptance | Enterprise | namespaces', function (hooks) {
'Navigates to the correct namespace when Enter is pressed'
);
// Test Cleanup
await deleteNamespaces(namespaces);
// Cleanup: Delete namespace(s) via the CLI
await deleteNSFromPaths(namespaces);
});
test('it filters namespaces based on search input', async function (assert) {
// Test Setup
// Setup: Create namespace(s) via the CLI
const namespaces = ['beep/boop/bop'];
await createNamespaces(namespaces);
await createNSFromPaths(namespaces);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
await click(GENERAL.toggleInput('namespace-id'));
await click(GENERAL.button('Refresh list'));
// Verify all namespaces are displayed initially
@ -163,51 +117,85 @@ module('Acceptance | Enterprise | namespaces', function (hooks) {
'All namespaces are displayed after clearing search input'
);
// Test Cleanup
await deleteNamespaces(namespaces);
// Cleanup: Delete namespace(s) via the CLI
await deleteNSFromPaths(namespaces);
});
test('it updates the namespace list after clicking "Refresh list"', async function (assert) {
// Test Setup
const namespaces = ['beep'];
await createNamespaces(namespaces);
// Open the namespace picker
await click(GENERAL.toggleInput('namespace-id'));
await click(NAMESPACE_PICKER_SELECTORS.toggle);
// Verify the search input field exists
assert.dom(NAMESPACE_PICKER_SELECTORS.searchInput).exists('The namespace search field exists');
// Verify that the namespace list was fetched on load
let listNamespaceRequests = fetchSpy
.getCalls()
.filter((call) => call.args[0].includes('/v1/sys/internal/ui/namespaces'));
// Verify 0 namespaces are displayed after searching for "beep"
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, 'beep');
assert.strictEqual(
listNamespaceRequests.length,
1,
'The network call to the specific endpoint was made twice (once on load, once on refresh)'
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
0,
'No namespaces are displayed after searching for "beep"'
);
// Close the namespace picker
await click(GENERAL.toggleInput('namespace-id'));
// Create 'beep' namespace via the CLI
const namespaces = ['beep'];
await createNSFromPaths(namespaces);
// Open the namespace picker
await click(GENERAL.toggleInput('namespace-id'));
// Refresh the list of namespaces
assert.dom(GENERAL.button('Refresh list')).exists('Refresh list button exists');
await click(GENERAL.button('Refresh list'));
// Verify that the namespace list was fetched on refresh
listNamespaceRequests = fetchSpy
.getCalls()
.filter((call) => call.args[0].includes('/v1/sys/internal/ui/namespaces'));
// Verify the search input field exists
assert.dom(NAMESPACE_PICKER_SELECTORS.searchInput).exists('The namespace search field exists');
// Verify 1 namespace is displayed after searching for "beep"
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, 'beep');
assert.strictEqual(
listNamespaceRequests.length,
2,
'The network call to the specific endpoint was made twice (once on load, once on refresh)'
findAll(NAMESPACE_PICKER_SELECTORS.link('beep')).length,
1,
'1 namespace is displayed after searching for "beep"'
);
// Test Cleanup
await deleteNamespaces(namespaces);
// Close the namespace picker
await click(GENERAL.toggleInput('namespace-id'));
// Delete the 'beep' namespace via the CLI
await deleteNSFromPaths(namespaces);
// Open the namespace picker
await click(GENERAL.toggleInput('namespace-id'));
// Refresh the list of namespaces
assert.dom(GENERAL.button('Refresh list')).exists('Refresh list button exists');
await click(GENERAL.button('Refresh list'));
// Verify the search input field exists
assert.dom(NAMESPACE_PICKER_SELECTORS.searchInput).exists('The namespace search field exists');
// Verify 0 namespaces are displayed after searching for "beep"
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, 'beep');
assert.strictEqual(
findAll(NAMESPACE_PICKER_SELECTORS.link()).length,
0,
'No namespaces are displayed after searching for "beep"'
);
// Close the namespace picker
await click(GENERAL.toggleInput('namespace-id'));
});
test('it displays the "Manage" button with the correct URL', async function (assert) {
// Test Setup
// Setup: Create namespace(s) via the CLI
const namespaces = ['beep'];
await createNamespaces(namespaces);
await createNSFromPaths(namespaces);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
// Open the namespace picker & refresh the list of namespaces
await click(GENERAL.toggleInput('namespace-id'));
await click(GENERAL.button('Refresh list'));
// Verify the "Manage" button is rendered and has the correct URL
@ -215,37 +203,54 @@ module('Acceptance | Enterprise | namespaces', function (hooks) {
.dom('[href="/ui/vault/access/namespaces"]')
.exists('The "Manage" button is displayed with the correct URL');
// Test Cleanup
await deleteNamespaces(namespaces);
// Cleanup: Delete namespace(s) via the CLI
await deleteNSFromPaths(namespaces);
});
// This test originated from this PR: https://github.com/hashicorp/vault/pull/7186
test('it clears namespaces when you log out', async function (assert) {
// Test Setup
const namespaces = ['foo'];
await createNamespaces(namespaces);
const namespace = 'foo';
await createNSFromPaths([namespace]);
const ns = 'foo';
await runCmd(createNS(ns), false);
const token = await runCmd(`write -field=client_token auth/token/create policies=default`);
await login(token);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
// Open the namespace picker
await click(GENERAL.toggleInput('namespace-id'));
// Verify that the root namespace is selected by default
assert.dom(NAMESPACE_PICKER_SELECTORS.link()).hasText('root', 'root renders as current namespace');
assert
.dom(`${NAMESPACE_PICKER_SELECTORS.link()} svg${GENERAL.icon('check')}`)
.exists('The root namespace is selected');
// Test Cleanup
await deleteNamespaces(namespaces);
// Verify that the foo namespace does not exist in the namespace picker
assert
.dom(NAMESPACE_PICKER_SELECTORS.link(namespace))
.exists({ count: 0 }, 'foo should not exist in the namespace picker');
// Logout and log back into root
await logout();
await login();
// Open the namespace picker & verify that the foo namespace does exist
await click(GENERAL.toggleInput('namespace-id'));
assert
.dom(NAMESPACE_PICKER_SELECTORS.link(namespace))
.exists({ count: 1 }, 'foo should exist in the namespace picker');
// Cleanup: Delete namespace(s) via the CLI
await deleteNSFromPaths([namespace]);
});
// This test originated from this PR: https://github.com/hashicorp/vault/pull/7186
test('it displays namespaces whether you log in with a namespace prefixed with / or not', async function (assert) {
// Test Setup
// Setup: Create namespace(s) via the CLI
const namespaces = ['beep/boop/bop'];
await createNamespaces(namespaces);
await createNSFromPaths(namespaces);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
await click(GENERAL.toggleInput('namespace-id'));
await click(GENERAL.button('Refresh list'));
// Login with a namespace prefixed with /
@ -253,11 +258,11 @@ module('Acceptance | Enterprise | namespaces', function (hooks) {
await settled();
assert
.dom(NAMESPACE_PICKER_SELECTORS.toggle)
.dom(GENERAL.toggleInput('namespace-id'))
.hasText('boop', `shows the namespace 'boop' in the toggle component`);
// Open the namespace picker & wait for it to render
await click(NAMESPACE_PICKER_SELECTORS.toggle);
await click(GENERAL.toggleInput('namespace-id'));
assert.dom(`svg${GENERAL.icon('check')}`).exists('The check icon is rendered');
// Find the selected element with the check icon & ensure it exists
@ -275,8 +280,8 @@ module('Acceptance | Enterprise | namespaces', function (hooks) {
'The current namespace does not begin or end with /'
);
// Test Cleanup
await deleteNamespaces(namespaces);
// Cleanup: Delete namespace(s) via the CLI
await deleteNSFromPaths(namespaces);
});
test('it shows the regular namespace toolbar when not managed', async function (assert) {
@ -295,37 +300,56 @@ module('Acceptance | Enterprise | namespaces', function (hooks) {
});
test('it should allow the user to delete a namespace', async function (assert) {
// Test Setup
const namespaces = ['test-delete-me'];
await createNamespaces(namespaces);
// Setup: Create namespace(s) via the CLI
const namespace = 'test-delete-me';
await createNSFromPaths([namespace]);
await visit('/vault/access/namespaces');
const searchInput = GENERAL.filterInputExplicit;
const searchButton = GENERAL.filterInputExplicitSearch;
// Verify that the namespace exists in the namespace picker
await click(GENERAL.toggleInput('namespace-id'));
await click(GENERAL.button('Refresh list'));
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, namespace);
await fillIn(searchInput, 'test-delete-me');
await click(searchButton);
assert
.dom(NAMESPACE_PICKER_SELECTORS.link(namespace))
.exists({ count: 1 }, 'Namespace exists in the namespace picker');
// Close the namespace picker
await click(GENERAL.toggleInput('namespace-id'));
// Verify that the namespace exists in the manage namespaces page
await fillIn(GENERAL.filterInputExplicit, namespace);
await click(GENERAL.filterInputExplicitSearch);
assert.dom(GENERAL.menuTrigger).exists();
await click(GENERAL.menuTrigger);
// Verify that the user can delete the namespace
const deleteNamespaceButton = '.hds-dropdown-list-item:nth-of-type(1)';
assert.dom(deleteNamespaceButton).hasText('Delete', 'Allow users to delete the namespace');
// Delete the namespace
const deleteNamespaceButton = '.hds-dropdown-list-item:nth-of-type(2)';
assert.dom(deleteNamespaceButton).hasText('Delete', 'Delete namespace button exists');
await click(`${deleteNamespaceButton} button`);
assert.dom(GENERAL.confirmButton).hasText('Confirm', 'Allow users to delete the namespace');
assert.dom(GENERAL.confirmButton).hasText('Confirm', 'Confirm namespace deletion button is shown');
await click(GENERAL.confirmButton);
// Verify that the namespace does not exist in the nmanage namespace page
assert.strictEqual(
currentURL(),
'/vault/access/namespaces?page=1&pageFilter=test-delete-me',
`/vault/access/namespaces?page=1&pageFilter=${namespace}`,
'Should remain on the manage namespaces page after deletion'
);
assert
.dom('.list-item-row')
.exists({ count: 0 }, 'Namespace should be deleted and not displayed in the list');
// Verify that the namespace does not exist in the namespace picker
await click(GENERAL.toggleInput('namespace-id'));
await click(GENERAL.button('Refresh list'));
await fillIn(NAMESPACE_PICKER_SELECTORS.searchInput, namespace);
assert
.dom(NAMESPACE_PICKER_SELECTORS.link())
.exists({ count: 0 }, 'Deleted namespace does not exist in the namespace picker');
});
});

View file

@ -23,6 +23,7 @@ import { create } from 'ember-cli-page-object';
import flashMessage from 'vault/tests/pages/components/flash-message';
import ss from 'vault/tests/pages/components/search-select';
import { disableReplication } from 'vault/tests/helpers/replication';
import { GENERAL } from '../helpers/general-selectors';
const searchSelect = create(ss);
const flash = create(flashMessage);
@ -272,7 +273,7 @@ module('Acceptance | Enterprise | replication', function (hooks) {
await click('[data-test-secondary-add]');
await fillIn('[data-test-replication-secondary-id]', secondaryNameSecond);
await click('[data-test-toggle-input]');
await click(GENERAL.toggleInput('Time to Live (TTL) for generated secondary token'));
await fillIn('[data-test-ttl-value]', 3);
await click('[data-test-secondary-add]');

View file

@ -10,6 +10,7 @@ import kubernetesScenario from 'vault/mirage/scenarios/kubernetes';
import kubernetesHandlers from 'vault/mirage/handlers/kubernetes';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import { fillIn, visit, click, currentRouteName } from '@ember/test-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
module('Acceptance | kubernetes | credentials', function (hooks) {
setupApplicationTest(hooks);
@ -66,8 +67,8 @@ module('Acceptance | kubernetes | credentials', function (hooks) {
};
});
await fillIn('[data-test-kubernetes-namespace]', 'kubernetes-test');
await click('[data-test-toggle-input]');
await click('[data-test-toggle-input="Time-to-Live (TTL)"]');
await click(GENERAL.toggleInput('kubernetes-clusterRoleBinding'));
await click(GENERAL.toggleInput('Time-to-Live (TTL)'));
await fillIn('[data-test-ttl-value="Time-to-Live (TTL)"]', 2);
await click('[data-test-generate-credentials-button]');
await click('[data-test-generate-credentials-done]');

View file

@ -3,8 +3,9 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { click, fillIn, findAll, triggerKeyEvent } from '@ember/test-helpers';
import { click, fillIn, findAll, triggerKeyEvent, visit } from '@ember/test-helpers';
// REPL selectors
const REPL = {
toggle: '[data-test-console-toggle]',
consoleInput: '[data-test-component="console/command-input"] input',
@ -13,49 +14,33 @@ const REPL = {
/**
* Helper functions to run common commands in the consoleComponent during tests.
* Please note that a user must be logged in during the test context for the commands to run.
* By default runCmd throws an error if the last log includes "Error". To override this,
* pass boolean false to run the commands and not throw errors
* Note: A user must be logged in during the test context for the commands to run.
*
* Example:
*
* import { v4 as uuidv4 } from 'uuid';
* import { runCmd, mountEngineCmd } from 'vault/tests/helpers/commands';
*
*
* async function mountEngineExitOnError() {
* const backend = `pki-${uuidv4()}`;
* await runCmd(mountEngineCmd('pki', backend));
* return backend;
* }
*
* async function mountEngineSquashErrors() {
* const backend = `pki-${uuidv4()}`;
* await runCmd(mountEngineCmd('pki', backend), false);
* return backend;
* async function mountEngine() {
* const backend = `pki-${uuidv4()}`;
* await runCmd(mountEngineCmd('pki', backend));
* return backend;
* }
*/
/**
* runCmd is used to run commands and throw an error if the output includes "Error"
* @param {string || string[]} commands array of commands that should run
* @param {boolean} throwErrors
* @returns the last log output. Throws an error if it includes an error
*/
// Command execution helpers
export const runCmd = async (commands, throwErrors = true) => {
if (!commands) {
throw new Error('runCmd requires commands array passed in');
}
if (!Array.isArray(commands)) {
commands = [commands];
}
if (!commands) throw new Error('runCmd requires commands array passed in');
if (!Array.isArray(commands)) commands = [commands];
await click(REPL.toggle);
await enterCommands(commands);
const lastOutput = await lastLogOutput();
await click(REPL.toggle);
if (throwErrors && lastOutput.includes('Error')) {
throw new Error(`Error occurred while running commands: "${commands.join('; ')}" - ${lastOutput}`);
}
return lastOutput;
};
@ -69,54 +54,93 @@ export const enterCommands = async (commands) => {
export const lastLogOutput = async () => {
const items = findAll(REPL.logOutputItems);
const count = items.length;
if (count === 0) {
// If no logOutput items are found, we can assume the response is empty
return '';
}
const outputItemText = items[count - 1].innerText;
return outputItemText;
if (!items.length) return '';
return items[items.length - 1].innerText;
};
// Common commands
export function mountEngineCmd(type, customName = '') {
// Command builders
export const mountEngineCmd = (type, customName = '') => {
const name = customName || type;
if (type === 'kv-v2') {
return `write sys/mounts/${name} type=kv options=version=2`;
}
return `write sys/mounts/${name} type=${type}`;
}
return type === 'kv-v2'
? `write sys/mounts/${name} type=kv options=version=2`
: `write sys/mounts/${name} type=${type}`;
};
export function deleteEngineCmd(name) {
return `delete sys/mounts/${name}`;
}
export const deleteEngineCmd = (name) => `delete sys/mounts/${name}`;
export function mountAuthCmd(type, customName = '') {
export const mountAuthCmd = (type, customName = '') => {
const name = customName || type;
return `write sys/auth/${name} type=${type}`;
}
export function deleteAuthCmd(name) {
return `delete sys/auth/${name}`;
}
export function createPolicyCmd(name, contents) {
const policyContent = window.btoa(contents);
return `write sys/policies/acl/${name} policy=${policyContent}`;
}
export function createTokenCmd(policyName = 'default') {
return `write -field=client_token auth/token/create policies=${policyName} ttl=1h`;
}
export const tokenWithPolicyCmd = function (name, policy) {
return [createPolicyCmd(name, policy), createTokenCmd(name)];
};
export function createNS(namespace) {
return `write sys/namespaces/${namespace} -f`;
}
export const deleteAuthCmd = (name) => `delete sys/auth/${name}`;
export function deleteNS(namespace) {
return `delete sys/namespaces/${namespace} -f`;
}
export const createPolicyCmd = (name, contents) => {
const policyContent = window.btoa(contents);
return `write sys/policies/acl/${name} policy=${policyContent}`;
};
export const createTokenCmd = (policyName = 'default') =>
`write -field=client_token auth/token/create policies=${policyName} ttl=1h`;
export const tokenWithPolicyCmd = (name, policy) => [createPolicyCmd(name, policy), createTokenCmd(name)];
export const createNS = (namespace) => `write sys/namespaces/${namespace} -f`;
export const deleteNS = (namespace) => `delete sys/namespaces/${namespace} -f`;
/**
* @description
* Iterates over an array of namespace paths and ensures each nested level is created.
* It visits the root namespace before attempting to create the next segment.
*
* @example input: ['foo/bar', 'baz/qux/quux']
* This will create: foo, foo/bar, baz, baz/qux, baz/qux/quux
*
* @param {string[]} namespaces - Array of strings of namespace paths (containing backslashes)
*/
export const createNSFromPaths = async (namespaces) => {
for (const ns of namespaces) {
const parts = ns.split('/');
let currentPath = '';
for (const part of parts) {
const url = `/vault/dashboard${currentPath && `?namespace=${currentPath.replaceAll('/', '%2F')}`}`;
await visit(url);
currentPath = currentPath ? `${currentPath}/${part}` : part;
await runCmd(createNS(part), false);
}
// Reset to root namespace after creating each path
await visit('/vault/dashboard');
}
};
/**
* @description
* Deletes namespaces by removing each segment of the path from deepest to top.
*
* @example input: ['foo/bar', 'baz/qux']
* This will delete: foo, baz
*
* @param {string[]} namespaces - Array of strings of namespace paths (containing backslashes)
*/
export const deleteNSFromPaths = async (namespaces) => {
for (const ns of namespaces) {
const parts = ns.split('/');
// Work from deepest child up to the top-level namespace
for (let i = parts.length - 1; i >= 0; i--) {
const parentPath = parts.slice(0, i).join('/');
const toDelete = parts[i];
// Build the URL for the parent namespace (or root if none)
const url = parentPath
? `/vault/dashboard?namespace=${parentPath.replaceAll('/', '%2F')}`
: '/vault/dashboard';
await visit(url);
await runCmd(deleteNS(toDelete), false);
}
// Reset to root namespace after deleting each path
await visit('/vault/dashboard');
}
};

View file

@ -5,6 +5,5 @@
export const NAMESPACE_PICKER_SELECTORS = {
link: (link) => (link ? `[data-test-namespace-link="${link}"]` : '[data-test-namespace-link]'),
toggle: '[data-test-namespace-toggle]',
searchInput: 'input[type="search"]',
};

View file

@ -178,7 +178,7 @@ module('Integration | Component | form field', function (hooks) {
})
);
assert.ok(component.hasToggleButton, 'renders a toggle button');
assert.dom('[data-test-toggle-input]').isNotChecked();
assert.dom(GENERAL.toggleInput('toggle-foobar')).isNotChecked();
assert.dom('[data-test-toggle-subtext]').hasText('Toggled off');
await component.fields.objectAt(0).toggleButton();

View file

@ -109,9 +109,8 @@ module('Integration | Component | kubernetes | Page::Credentials', function (hoo
await this.renderComponent();
await fillIn('[data-test-kubernetes-namespace]', 'kubernetes-test');
assert.dom('[data-test-kubernetes-namespace]').hasValue('kubernetes-test', 'kubernetes-test');
await click('[data-test-toggle-input]');
await click('[data-test-toggle-input="Time-to-Live (TTL)"]');
await click(GENERAL.toggleInput('kubernetes-clusterRoleBinding'));
await click(GENERAL.toggleInput('Time-to-Live (TTL)'));
await fillIn('[data-test-ttl-value="Time-to-Live (TTL)"]', 2);
await click('[data-test-generate-credentials-button]');

View file

@ -62,7 +62,7 @@ module('Integration | Component | namespace-picker', function (hooks) {
test('it focuses the search input field when the component is loaded', async function (assert) {
await render(hbs`<NamespacePicker />`);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
await click(GENERAL.toggleInput('namespace-id'));
// Verify that the search input field is focused
const searchInput = find(NAMESPACE_PICKER_SELECTORS.searchInput);
@ -75,7 +75,7 @@ module('Integration | Component | namespace-picker', function (hooks) {
test('it filters namespace options based on search input', async function (assert) {
await render(hbs`<NamespacePicker/>`);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
await click(GENERAL.toggleInput('namespace-id'));
// Verify all namespaces are displayed initially
await waitFor(NAMESPACE_PICKER_SELECTORS.link());
@ -117,7 +117,7 @@ module('Integration | Component | namespace-picker', function (hooks) {
});
await render(hbs`<NamespacePicker />`);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
await click(GENERAL.toggleInput('namespace-id'));
// Verify that the "Refresh List" button is visible
assert.dom(GENERAL.button('Refresh list')).exists('Refresh List button is visible');
@ -134,7 +134,7 @@ module('Integration | Component | namespace-picker', function (hooks) {
});
await render(hbs`<NamespacePicker />`);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
await click(GENERAL.toggleInput('namespace-id'));
// Verify that the buttons are hidden
assert.dom(GENERAL.button('Refresh list')).doesNotExist('Refresh List button is hidden');
@ -148,7 +148,7 @@ module('Integration | Component | namespace-picker', function (hooks) {
});
await render(hbs`<NamespacePicker />`);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
await click(GENERAL.toggleInput('namespace-id'));
// Verify that the buttons are hidden
assert.dom(GENERAL.button('Refresh list')).doesNotExist('Refresh List button is hidden');
@ -167,7 +167,7 @@ module('Integration | Component | namespace-picker', function (hooks) {
});
await render(hbs`<NamespacePicker />`);
await click(NAMESPACE_PICKER_SELECTORS.toggle);
await click(GENERAL.toggleInput('namespace-id'));
// Dynamically modify the `findNamespacesForUser.perform` method for this test
const namespaceService = this.owner.lookup('service:namespace');

View file

@ -9,7 +9,7 @@ import { render, click } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon';
import { setRunOptions } from 'ember-a11y-testing/test-support';
import { NAMESPACE_PICKER_SELECTORS } from 'vault/tests/helpers/namespace-picker';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
module('Integration | Component | sidebar-frame', function (hooks) {
setupRenderingTest(hooks);
@ -89,6 +89,6 @@ module('Integration | Component | sidebar-frame', function (hooks) {
<Sidebar::Frame @showSidebar={{true}} />
`);
assert.dom(NAMESPACE_PICKER_SELECTORS.toggle).exists('Namespace picker renders in sidebar footer');
assert.dom(GENERAL.toggleInput('namespace-id')).exists('Namespace picker renders in sidebar footer');
});
});