diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx index 72fd79805f970..6e3de061fd191 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx @@ -5,13 +5,7 @@ * 2.0. */ -import { - fireEvent, - render, - waitFor, - waitForElementToBeRemoved, - within, -} from '@testing-library/react'; +import { render } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import React from 'react'; @@ -22,60 +16,75 @@ import { Providers } from '../api_keys_management_app'; import { apiKeysAPIClientMock } from '../index.mock'; import { APIKeysGridPage } from './api_keys_grid_page'; -jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ - htmlIdGenerator: () => () => `id-${Math.random()}`, -})); +/* + * Note to engineers + * we moved these 4 tests below to "x-pack/test/functional/apps/api_keys/home_page.ts": + * 1-"creates API key when submitting form, redirects back and displays base64" + * 2-"creates API key with optional expiration, redirects back and displays base64" + * 3-"deletes multiple api keys using bulk select" + * 4-"deletes api key using cta button" + * to functional tests to avoid flakyness + */ -jest.setTimeout(15000); +describe('APIKeysGridPage', () => { + // We are spying on the console.error to avoid react to throw error + // in our test "displays error when fetching API keys fails" + // since we are using EuiErrorBoundary and react will console.error any errors + const consoleWarnMock = jest.spyOn(console, 'error').mockImplementation(); -const coreStart = coreMock.createStart(); + const coreStart = coreMock.createStart(); + const apiClientMock = apiKeysAPIClientMock.create(); + const { authc } = securityMock.createSetup(); -const apiClientMock = apiKeysAPIClientMock.create(); -apiClientMock.checkPrivileges.mockResolvedValue({ - areApiKeysEnabled: true, - canManage: true, - isAdmin: true, -}); -apiClientMock.getApiKeys.mockResolvedValue({ - apiKeys: [ - { - creation: 1571322182082, - expiration: 1571408582082, - id: '0QQZ2m0BO2XZwgJFuWTT', - invalidated: false, - name: 'first-api-key', - realm: 'reserved', - username: 'elastic', - }, - { - creation: 1571322182082, - expiration: 1571408582082, - id: 'BO2XZwgJFuWTT0QQZ2m0', - invalidated: false, - name: 'second-api-key', - realm: 'reserved', - username: 'elastic', - }, - ], -}); + beforeEach(() => { + apiClientMock.checkPrivileges.mockClear(); + apiClientMock.getApiKeys.mockClear(); + coreStart.http.get.mockClear(); + coreStart.http.post.mockClear(); + authc.getCurrentUser.mockClear(); -const authc = securityMock.createSetup().authc; -authc.getCurrentUser.mockResolvedValue( - mockAuthenticatedUser({ - username: 'jdoe', - full_name: '', - email: '', - enabled: true, - roles: ['superuser'], - }) -); + apiClientMock.checkPrivileges.mockResolvedValue({ + areApiKeysEnabled: true, + canManage: true, + isAdmin: true, + }); + apiClientMock.getApiKeys.mockResolvedValue({ + apiKeys: [ + { + creation: 1571322182082, + expiration: 1571408582082, + id: '0QQZ2m0BO2XZwgJFuWTT', + invalidated: false, + name: 'first-api-key', + realm: 'reserved', + username: 'elastic', + }, + { + creation: 1571322182082, + expiration: 1571408582082, + id: 'BO2XZwgJFuWTT0QQZ2m0', + invalidated: false, + name: 'second-api-key', + realm: 'reserved', + username: 'elastic', + }, + ], + }); -// FLAKY: https://github.com/elastic/kibana/issues/97085 -describe.skip('APIKeysGridPage', () => { + authc.getCurrentUser.mockResolvedValue( + mockAuthenticatedUser({ + username: 'jdoe', + full_name: '', + email: '', + enabled: true, + roles: ['superuser'], + }) + ); + }); it('loads and displays API keys', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }); - const { getByText } = render( + const { findByText } = render( { ); - await waitForElementToBeRemoved(() => getByText(/Loading API keys/)); - getByText(/first-api-key/); - getByText(/second-api-key/); + expect(await findByText(/Loading API keys/)).not.toBeInTheDocument(); + await findByText(/first-api-key/); + await findByText(/second-api-key/); + }); + + afterAll(() => { + // Let's make sure we restore everything just in case + consoleWarnMock.mockRestore(); }); it('displays callout when API keys are disabled', async () => { @@ -98,7 +112,7 @@ describe.skip('APIKeysGridPage', () => { isAdmin: true, }); - const { getByText } = render( + const { findByText } = render( { ); - await waitForElementToBeRemoved(() => getByText(/Loading API keys/)); - getByText(/API keys not enabled/); + expect(await findByText(/Loading API keys/)).not.toBeInTheDocument(); + await findByText(/API keys not enabled/); }); it('displays error when user does not have required permissions', async () => { @@ -120,7 +134,7 @@ describe.skip('APIKeysGridPage', () => { isAdmin: false, }); - const { getByText } = render( + const { findByText } = render( { ); - await waitForElementToBeRemoved(() => getByText(/Loading API keys/)); - getByText(/You need permission to manage API keys/); + expect(await findByText(/Loading API keys/)).not.toBeInTheDocument(); + await findByText(/You need permission to manage API keys/); }); it('displays error when fetching API keys fails', async () => { apiClientMock.getApiKeys.mockRejectedValueOnce({ - body: { error: 'Internal Server Error', message: '', statusCode: 500 }, - }); - const history = createMemoryHistory({ initialEntries: ['/'] }); - - const { getByText } = render( - - - - ); - - await waitForElementToBeRemoved(() => getByText(/Loading API keys/)); - getByText(/Could not load API keys/); - }); - - it('creates API key when submitting form, redirects back and displays base64', async () => { - const history = createMemoryHistory({ initialEntries: ['/create'] }); - coreStart.http.get.mockResolvedValue([{ name: 'superuser' }]); - coreStart.http.post.mockResolvedValue({ id: '1D', api_key: 'AP1_K3Y' }); - - const { findByRole, findByDisplayValue } = render( - - - - ); - expect(coreStart.http.get).toHaveBeenCalledWith('/api/security/role'); - - const dialog = await findByRole('dialog'); - - fireEvent.click(await findByRole('button', { name: 'Create API key' })); - - const alert = await findByRole('alert'); - within(alert).getByText(/Enter a name/i); - - fireEvent.change(await within(dialog).findByLabelText('Name'), { - target: { value: 'Test' }, - }); - - fireEvent.click(await findByRole('button', { name: 'Create API key' })); - - await waitFor(() => { - expect(coreStart.http.post).toHaveBeenLastCalledWith('/internal/security/api_key', { - body: JSON.stringify({ name: 'Test' }), - }); - expect(history.location.pathname).toBe('/'); - }); - - await findByDisplayValue(btoa('1D:AP1_K3Y')); - }); - - it('creates API key with optional expiration, redirects back and displays base64', async () => { - const history = createMemoryHistory({ initialEntries: ['/create'] }); - coreStart.http.get.mockResolvedValue([{ name: 'superuser' }]); - coreStart.http.post.mockResolvedValue({ id: '1D', api_key: 'AP1_K3Y' }); - - const { findByRole, findByDisplayValue } = render( - - - - ); - expect(coreStart.http.get).toHaveBeenCalledWith('/api/security/role'); - - const dialog = await findByRole('dialog'); - - fireEvent.change(await within(dialog).findByLabelText('Name'), { - target: { value: 'Test' }, - }); - - fireEvent.click(await within(dialog).findByLabelText('Expire after time')); - - fireEvent.click(await findByRole('button', { name: 'Create API key' })); - - const alert = await findByRole('alert'); - within(alert).getByText(/Enter a valid duration or disable this option\./i); - - fireEvent.change(await within(dialog).findByLabelText('Lifetime (days)'), { - target: { value: '12' }, - }); - - fireEvent.click(await findByRole('button', { name: 'Create API key' })); - - await waitFor(() => { - expect(coreStart.http.post).toHaveBeenLastCalledWith('/internal/security/api_key', { - body: JSON.stringify({ name: 'Test', expiration: '12d' }), - }); - expect(history.location.pathname).toBe('/'); + body: { + error: 'Internal Server Error', + message: 'Internal Server Error', + statusCode: 500, + }, }); - - await findByDisplayValue(btoa('1D:AP1_K3Y')); - }); - - it('deletes api key using cta button', async () => { - const history = createMemoryHistory({ initialEntries: ['/'] }); - - const { findByRole, findAllByLabelText } = render( - - - - ); - - const [deleteButton] = await findAllByLabelText(/Delete/i); - fireEvent.click(deleteButton); - - const dialog = await findByRole('dialog'); - fireEvent.click(await within(dialog).findByRole('button', { name: 'Delete API key' })); - - await waitFor(() => { - expect(apiClientMock.invalidateApiKeys).toHaveBeenLastCalledWith( - [{ id: '0QQZ2m0BO2XZwgJFuWTT', name: 'first-api-key' }], - true - ); - }); - }); - - it('deletes multiple api keys using bulk select', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }); - const { findByRole, findAllByRole } = render( + const { findByText } = render( { ); - const deleteCheckboxes = await findAllByRole('checkbox', { name: 'Select this row' }); - deleteCheckboxes.forEach((checkbox) => fireEvent.click(checkbox)); - fireEvent.click(await findByRole('button', { name: 'Delete API keys' })); - - const dialog = await findByRole('dialog'); - fireEvent.click(await within(dialog).findByRole('button', { name: 'Delete API keys' })); - - await waitFor(() => { - expect(apiClientMock.invalidateApiKeys).toHaveBeenLastCalledWith( - [ - { id: '0QQZ2m0BO2XZwgJFuWTT', name: 'first-api-key' }, - { id: 'BO2XZwgJFuWTT0QQZ2m0', name: 'second-api-key' }, - ], - true - ); - }); + expect(await findByText(/Loading API keys/)).not.toBeInTheDocument(); + await findByText(/Could not load API keys/); }); }); diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx index dcf2a7bfe5165..a4843e4637d8b 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx @@ -164,6 +164,7 @@ export class APIKeysGridPage extends Component { {...reactRouterNavigate(this.props.history, '/create')} fill iconType="plusInCircleFilled" + data-test-subj="apiKeysCreatePromptButton" > { {...reactRouterNavigate(this.props.history, '/create')} fill iconType="plusInCircleFilled" + data-test-subj="apiKeysCreateTableButton" > { color: 'danger', onClick: (item) => invalidateApiKeyPrompt([{ id: item.id, name: item.name }], this.onApiKeysInvalidated), + 'data-test-subj': 'apiKeysTableDeleteAction', }, ], }, diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/create_api_key_flyout.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/create_api_key_flyout.tsx index e1ffc3b4b3515..f2fa6f7de468e 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/create_api_key_flyout.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/create_api_key_flyout.tsx @@ -202,6 +202,7 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ isInvalid={form.touched.name && !!form.errors.name} inputRef={firstFieldRef} fullWidth + data-test-subj="apiKeyNameInput" /> @@ -258,6 +259,7 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ )} checked={!!form.values.customExpiration} onChange={(e) => form.setValue('customExpiration', e.target.checked)} + data-test-subj="apiKeyCustomExpirationSwitch" /> {form.values.customExpiration && ( <> @@ -284,6 +286,7 @@ export const CreateApiKeyFlyout: FunctionComponent = ({ defaultValue={form.values.expiration} isInvalid={form.touched.expiration && !!form.errors.expiration} fullWidth + data-test-subj="apiKeyCustomExpirationInput" /> diff --git a/x-pack/test/functional/apps/api_keys/home_page.ts b/x-pack/test/functional/apps/api_keys/home_page.ts index be8f128359345..5907247527585 100644 --- a/x-pack/test/functional/apps/api_keys/home_page.ts +++ b/x-pack/test/functional/apps/api_keys/home_page.ts @@ -5,6 +5,7 @@ * 2.0. */ +import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { @@ -13,6 +14,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const security = getService('security'); const testSubjects = getService('testSubjects'); const find = getService('find'); + const browser = getService('browser'); describe('Home page', function () { before(async () => { @@ -34,5 +36,90 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { log.debug('Checking for create API key call to action'); await find.existsByLinkText('Create API key'); }); + + describe('creates API key', function () { + before(async () => { + await security.testUser.setRoles(['kibana_admin']); + await security.testUser.setRoles(['test_api_keys']); + await pageObjects.common.navigateToApp('apiKeys'); + }); + + afterEach(async () => { + await pageObjects.apiKeys.deleteAllApiKeyOneByOne(); + }); + + it('when submitting form, close dialog and displays new api key', async () => { + const apiKeyName = 'Happy API Key'; + await pageObjects.apiKeys.clickOnPromptCreateApiKey(); + expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/create'); + + await pageObjects.apiKeys.setApiKeyName(apiKeyName); + await pageObjects.apiKeys.submitOnCreateApiKey(); + const newApiKeyCreation = await pageObjects.apiKeys.getNewApiKeyCreation(); + + expect(await browser.getCurrentUrl()).to.not.contain( + 'app/management/security/api_keys/create' + ); + expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); + expect(await pageObjects.apiKeys.isApiKeyModalExists()).to.be(false); + expect(newApiKeyCreation).to.be(`Created API key '${apiKeyName}'`); + }); + + it('with optional expiration, redirects back and displays base64', async () => { + const apiKeyName = 'Happy expiration API key'; + await pageObjects.apiKeys.clickOnPromptCreateApiKey(); + expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/create'); + + await pageObjects.apiKeys.setApiKeyName(apiKeyName); + await pageObjects.apiKeys.toggleCustomExpiration(); + await pageObjects.apiKeys.submitOnCreateApiKey(); + expect(await pageObjects.apiKeys.getErrorCallOutText()).to.be( + 'Enter a valid duration or disable this option.' + ); + + await pageObjects.apiKeys.setApiKeyCustomExpiration('12'); + await pageObjects.apiKeys.submitOnCreateApiKey(); + const newApiKeyCreation = await pageObjects.apiKeys.getNewApiKeyCreation(); + + expect(await browser.getCurrentUrl()).to.not.contain( + 'app/management/security/api_keys/create' + ); + expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); + expect(await pageObjects.apiKeys.isApiKeyModalExists()).to.be(false); + expect(newApiKeyCreation).to.be(`Created API key '${apiKeyName}'`); + }); + }); + + describe('deletes API key(s)', function () { + before(async () => { + await security.testUser.setRoles(['kibana_admin']); + await security.testUser.setRoles(['test_api_keys']); + await pageObjects.common.navigateToApp('apiKeys'); + }); + + beforeEach(async () => { + await pageObjects.apiKeys.clickOnPromptCreateApiKey(); + await pageObjects.apiKeys.setApiKeyName('api key 1'); + await pageObjects.apiKeys.submitOnCreateApiKey(); + }); + + it('one by one', async () => { + await pageObjects.apiKeys.deleteAllApiKeyOneByOne(); + expect(await pageObjects.apiKeys.getApiKeysFirstPromptTitle()).to.be( + 'Create your first API key' + ); + }); + + it('by bulk', async () => { + await pageObjects.apiKeys.clickOnTableCreateApiKey(); + await pageObjects.apiKeys.setApiKeyName('api key 2'); + await pageObjects.apiKeys.submitOnCreateApiKey(); + + await pageObjects.apiKeys.bulkDeleteApiKeys(); + expect(await pageObjects.apiKeys.getApiKeysFirstPromptTitle()).to.be( + 'Create your first API key' + ); + }); + }); }); }; diff --git a/x-pack/test/functional/page_objects/api_keys_page.ts b/x-pack/test/functional/page_objects/api_keys_page.ts index 99c2aa01a4eda..9349eaa4bda0c 100644 --- a/x-pack/test/functional/page_objects/api_keys_page.ts +++ b/x-pack/test/functional/page_objects/api_keys_page.ts @@ -9,6 +9,7 @@ import { FtrProviderContext } from '../ftr_provider_context'; export function ApiKeysPageProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); + const find = getService('find'); return { async noAPIKeysHeading() { @@ -26,5 +27,70 @@ export function ApiKeysPageProvider({ getService }: FtrProviderContext) { async apiKeysPermissionDeniedMessage() { return await testSubjects.getVisibleText('apiKeysPermissionDeniedMessage'); }, + + async clickOnPromptCreateApiKey() { + return await testSubjects.click('apiKeysCreatePromptButton'); + }, + + async clickOnTableCreateApiKey() { + return await testSubjects.click('apiKeysCreateTableButton'); + }, + + async setApiKeyName(apiKeyName: string) { + return await testSubjects.setValue('apiKeyNameInput', apiKeyName); + }, + + async setApiKeyCustomExpiration(expirationTime: string) { + return await testSubjects.setValue('apiKeyCustomExpirationInput', expirationTime); + }, + + async submitOnCreateApiKey() { + return await testSubjects.click('formFlyoutSubmitButton'); + }, + + async isApiKeyModalExists() { + return await find.existsByCssSelector('[role="dialog"]'); + }, + + async getNewApiKeyCreation() { + const euiCallOutHeader = await find.byCssSelector('.euiCallOutHeader__title'); + return euiCallOutHeader.getVisibleText(); + }, + + async toggleCustomExpiration() { + return await testSubjects.click('apiKeyCustomExpirationSwitch'); + }, + + async getErrorCallOutText() { + const alertElem = await find.byCssSelector('[role="dialog"] [role="alert"] .euiText'); + return await alertElem.getVisibleText(); + }, + + async getApiKeysFirstPromptTitle() { + const titlePromptElem = await find.byCssSelector('.euiEmptyPrompt .euiTitle'); + return await titlePromptElem.getVisibleText(); + }, + + async deleteAllApiKeyOneByOne() { + const hasApiKeysToDelete = await testSubjects.exists('apiKeysTableDeleteAction'); + if (hasApiKeysToDelete) { + const apiKeysToDelete = await testSubjects.findAll('apiKeysTableDeleteAction'); + for (const element of apiKeysToDelete) { + await element.click(); + await testSubjects.click('confirmModalConfirmButton'); + } + } + }, + + async bulkDeleteApiKeys() { + const hasApiKeysToDelete = await testSubjects.exists('checkboxSelectAll', { + allowHidden: true, + }); + if (hasApiKeysToDelete) { + await testSubjects.click('checkboxSelectAll'); + await testSubjects.click('bulkInvalidateActionButton'); + await testSubjects.click('confirmModalConfirmButton'); + } + }, }; }