[Fleet][Agent Tamper Protection] Uninstall token table (#161760)

## Summary

This PR adds an `Uninstall Tokens` tab to Fleet, where the user can:
- see uninstall tokens for all policies
- filter by policy ID (partial match)
- view the hidden token by pressing the 👁️ button
- open the Uninstall Command flyout to view the uninstall command for
the selected policy

<img
src="0a98e26e-09df-40e4-81de-02afebc5f830)"
width="500px" />
<img
src="c41d0841-b496-440b-bfb4-cd28907e3196)"
width="500px" />
<img
src="c5c613b9-9166-4bcc-8d7a-4a7b34f0698e)"
width="500px" />



### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Gergő Ábrahám 2023-07-17 13:03:22 +02:00 committed by GitHub
parent b343bf9611
commit d5996d7487
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 872 additions and 177 deletions

View file

@ -26,10 +26,13 @@ import {
SETTINGS_FLEET_SERVER_HOST_HEADING,
FLEET_SERVER_SETUP,
LANDING_PAGE_ADD_FLEET_SERVER_BUTTON,
UNINSTALL_TOKENS_TAB,
UNINSTALL_TOKENS,
} from '../../screens/fleet';
import { AGENT_POLICY_NAME_LINK } from '../../screens/integrations';
import { cleanupAgentPolicies, unenrollAgent } from '../../tasks/cleanup';
import { setFleetServerHost } from '../../tasks/fleet_server';
describe('Home page', () => {
before(() => {
setFleetServerHost('https://fleetserver:8220');
@ -143,6 +146,38 @@ describe('Home page', () => {
});
});
describe('Uninstall Tokens', () => {
before(() => {
cy.request({
method: 'POST',
url: '/api/fleet/agent_policies',
body: { name: 'Agent policy for A11y test', namespace: 'default', id: 'agent-policy-a11y' },
headers: { 'kbn-xsrf': 'cypress' },
});
});
beforeEach(() => {
navigateTo(FLEET);
cy.getBySel(UNINSTALL_TOKENS_TAB).click();
});
after(() => {
cy.request({
method: 'POST',
url: '/api/fleet/agent_policies/delete',
body: { agentPolicyId: 'agent-policy-a11y' },
headers: { 'kbn-xsrf': 'kibana' },
});
});
it('Uninstall Tokens Table', () => {
cy.getBySel(UNINSTALL_TOKENS.POLICY_ID_TABLE_FIELD).first().should('be.visible');
checkA11y({ skipFailures: false });
});
it('Uninstall Command Flyout', () => {
cy.getBySel(UNINSTALL_TOKENS.VIEW_UNINSTALL_COMMAND_BUTTON).first().click();
cy.getBySel(UNINSTALL_TOKENS.UNINSTALL_COMMAND_FLYOUT).should('be.visible');
checkA11y({ skipFailures: false });
});
});
describe('Data Streams', () => {
before(() => {
navigateTo(FLEET);

View file

@ -5,13 +5,11 @@
* 2.0.
*/
import { FLEET, navigateTo } from '../tasks/navigation';
import { cleanupAgentPolicies } from '../tasks/cleanup';
import { ENROLLMENT_TOKENS_TAB, ENROLLMENT_TOKENS } from '../screens/fleet';
import { ENROLLMENT_TOKENS } from '../screens/fleet';
describe('Enrollment token page', () => {
before(() => {
navigateTo(FLEET);
cy.request({
method: 'POST',
url: '/api/fleet/agent_policies',
@ -24,9 +22,6 @@ describe('Enrollment token page', () => {
},
headers: { 'kbn-xsrf': 'cypress' },
});
cy.getBySel(ENROLLMENT_TOKENS_TAB, {
timeout: 15000,
}).click();
});
after(() => {
@ -34,6 +29,7 @@ describe('Enrollment token page', () => {
});
it('Create new Token', () => {
cy.visit('app/fleet/enrollment-tokens');
cy.getBySel(ENROLLMENT_TOKENS.CREATE_TOKEN_BUTTON).click();
cy.getBySel(ENROLLMENT_TOKENS.CREATE_TOKEN_MODAL_NAME_FIELD).clear().type('New Token');
cy.getBySel(ENROLLMENT_TOKENS.CREATE_TOKEN_MODAL_SELECT_FIELD).contains('Agent policy 1');

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UninstallToken } from '../../common/types/models/uninstall_token';
import { cleanupAgentPolicies } from '../tasks/cleanup';
import { UNINSTALL_TOKENS } from '../screens/fleet';
import type { GetUninstallTokenResponse } from '../../common/types/rest_spec/uninstall_token';
describe('Uninstall token page', () => {
before(() => {
cleanupAgentPolicies();
generatePolicies();
});
beforeEach(() => {
cy.visit('app/fleet/uninstall-tokens');
cy.intercept('GET', 'api/fleet/uninstall_tokens/*').as('getTokenRequest');
cy.getBySel(UNINSTALL_TOKENS.POLICY_ID_TABLE_FIELD)
.first()
.then(($policyIdField) => $policyIdField[0].textContent)
.as('policyIdInFirstLine');
});
after(() => {
cleanupAgentPolicies();
});
it('should show token by clicking on the eye button', () => {
// tokens are hidden by default
cy.getBySel(UNINSTALL_TOKENS.TOKEN_FIELD).each(($tokenField) => {
expect($tokenField).to.contain.text('••••••••••••••••••••••••••••••••');
});
// token is reveiled when clicking on eye button
cy.getBySel(UNINSTALL_TOKENS.SHOW_HIDE_TOKEN_BUTTON).first().click();
// we should show the correct token for the correct policy ID
waitForFetchingUninstallToken().then((fetchedToken) => {
cy.get('@policyIdInFirstLine').should('equal', fetchedToken.policy_id);
cy.getBySel(UNINSTALL_TOKENS.TOKEN_FIELD)
.first()
.should('not.contain.text', '••••••••••••••••••••••••••••••••')
.should('contain.text', fetchedToken.token);
});
});
it("should show flyout by clicking on 'View uninstall command' button", () => {
cy.getBySel(UNINSTALL_TOKENS.VIEW_UNINSTALL_COMMAND_BUTTON).first().click();
waitForFetchingUninstallToken().then((fetchedToken) => {
cy.get('@policyIdInFirstLine').should('equal', fetchedToken.policy_id);
cy.getBySel(UNINSTALL_TOKENS.UNINSTALL_COMMAND_FLYOUT).should('exist');
cy.contains(`sudo elastic-agent uninstall --uninstall-token ${fetchedToken.token}`);
cy.contains(`Valid for the following agent policy: ${fetchedToken.policy_id}`);
});
});
it('should filter for policy ID by partial match', () => {
cy.getBySel(UNINSTALL_TOKENS.POLICY_ID_TABLE_FIELD).should('have.length.at.least', 3);
cy.getBySel(UNINSTALL_TOKENS.POLICY_ID_SEARCH_FIELD).type('licy-300');
cy.getBySel(UNINSTALL_TOKENS.POLICY_ID_TABLE_FIELD).should('have.length', 1);
});
const generatePolicies = () => {
for (let i = 1; i <= 3; i++) {
cy.request({
method: 'POST',
url: '/api/fleet/agent_policies',
body: { name: `Agent policy ${i}00`, namespace: 'default', id: `agent-policy-${i}00` },
headers: { 'kbn-xsrf': 'cypress' },
});
}
};
const waitForFetchingUninstallToken = (): Cypress.Chainable<UninstallToken> =>
cy
.wait('@getTokenRequest')
.then((interception) => (interception.response?.body as GetUninstallTokenResponse).item);
});

View file

@ -12,6 +12,7 @@ export const LANDING_PAGE_ADD_FLEET_SERVER_BUTTON = 'fleetServerLanding.addFleet
export const AGENTS_TAB = 'fleet-agents-tab';
export const AGENT_POLICIES_TAB = 'fleet-agent-policies-tab';
export const ENROLLMENT_TOKENS_TAB = 'fleet-enrollment-tokens-tab';
export const UNINSTALL_TOKENS_TAB = 'fleet-uninstall-tokens-tab';
export const DATA_STREAMS_TAB = 'fleet-datastreams-tab';
export const SETTINGS_TAB = 'fleet-settings-tab';
@ -51,6 +52,16 @@ export const ENROLLMENT_TOKENS = {
LIST_TABLE: 'enrollmentTokenListTable',
TABLE_REVOKE_BTN: 'enrollmentTokenTable.revokeBtn',
};
export const UNINSTALL_TOKENS = {
POLICY_ID_SEARCH_FIELD: 'uninstallTokensPolicyIdSearchInput',
POLICY_ID_TABLE_FIELD: 'uninstallTokensPolicyIdField',
VIEW_UNINSTALL_COMMAND_BUTTON: 'uninstallTokensViewCommandButton',
UNINSTALL_COMMAND_FLYOUT: 'uninstall-command-flyout',
TOKEN_FIELD: 'apiKeyField',
SHOW_HIDE_TOKEN_BUTTON: 'showHideTokenButton',
};
export const SETTINGS_FLEET_SERVER_HOST_HEADING = 'fleetServerHostHeader';
export const SETTINGS_SAVE_BTN = 'saveApplySettingsBtn';

View file

@ -60,8 +60,10 @@ import { AgentsApp } from './sections/agents';
import { MissingESRequirementsPage } from './sections/agents/agent_requirements_page';
import { CreatePackagePolicyPage } from './sections/agent_policy/create_package_policy_page';
import { EnrollmentTokenListPage } from './sections/agents/enrollment_token_list_page';
import { UninstallTokenListPage } from './sections/agents/uninstall_token_list_page';
import { SettingsApp } from './sections/settings';
import { DebugPage } from './sections/debug';
import { ExperimentalFeaturesService } from './services';
const FEEDBACK_URL = 'https://ela.st/fleet-feedback';
@ -320,6 +322,7 @@ export const AppRoutes = memo(
({ setHeaderActionMenu }: { setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'] }) => {
const flyoutContext = useFlyoutContext();
const fleetStatus = useFleetStatus();
const { agentTamperProtectionEnabled } = ExperimentalFeaturesService.get();
return (
<>
@ -335,6 +338,11 @@ export const AppRoutes = memo(
<Route path={FLEET_ROUTING_PATHS.enrollment_tokens}>
<EnrollmentTokenListPage />
</Route>
{agentTamperProtectionEnabled && (
<Route path={FLEET_ROUTING_PATHS.uninstall_tokens}>
<UninstallTokenListPage />
</Route>
)}
<Route path={FLEET_ROUTING_PATHS.data_streams}>
<DataStreamApp />
</Route>

View file

@ -139,6 +139,14 @@ const breadcrumbGetters: {
}),
},
],
uninstall_tokens: () => [
BASE_BREADCRUMB,
{
text: i18n.translate('xpack.fleet.breadcrumbs.uninstallTokensPageTitle', {
defaultMessage: 'Uninstall tokens',
}),
},
],
data_streams: () => [
BASE_BREADCRUMB,
{

View file

@ -12,6 +12,8 @@ import type { Section } from '../../sections';
import { useLink, useConfig } from '../../hooks';
import { WithHeaderLayout } from '../../../../layouts';
import { ExperimentalFeaturesService } from '../../services';
import { DefaultPageTitle } from './default_page_title';
interface Props {
@ -27,70 +29,79 @@ export const DefaultLayout: React.FunctionComponent<Props> = ({
}) => {
const { getHref } = useLink();
const { agents } = useConfig();
const { agentTamperProtectionEnabled } = ExperimentalFeaturesService.get();
const tabs = [
{
name: (
<FormattedMessage id="xpack.fleet.appNavigation.agentsLinkText" defaultMessage="Agents" />
),
isSelected: section === 'agents',
href: getHref('agent_list'),
disabled: !agents?.enabled,
'data-test-subj': 'fleet-agents-tab',
},
{
name: (
<FormattedMessage
id="xpack.fleet.appNavigation.policiesLinkText"
defaultMessage="Agent policies"
/>
),
isSelected: section === 'agent_policies',
href: getHref('policies_list'),
'data-test-subj': 'fleet-agent-policies-tab',
},
{
name: (
<FormattedMessage
id="xpack.fleet.appNavigation.enrollmentTokensText"
defaultMessage="Enrollment tokens"
/>
),
isSelected: section === 'enrollment_tokens',
href: getHref('enrollment_tokens'),
'data-test-subj': 'fleet-enrollment-tokens-tab',
},
{
name: (
<FormattedMessage
id="xpack.fleet.appNavigation.uninstallTokensText"
defaultMessage="Uninstall tokens"
/>
),
isSelected: section === 'uninstall_tokens',
href: getHref('uninstall_tokens'),
'data-test-subj': 'fleet-uninstall-tokens-tab',
isHidden: !agentTamperProtectionEnabled, // needed only for agentTamperProtectionEnabled feature flag
},
{
name: (
<FormattedMessage
id="xpack.fleet.appNavigation.dataStreamsLinkText"
defaultMessage="Data streams"
/>
),
isSelected: section === 'data_streams',
href: getHref('data_streams'),
'data-test-subj': 'fleet-datastreams-tab',
},
{
name: (
<FormattedMessage
id="xpack.fleet.appNavigation.settingsLinkText"
defaultMessage="Settings"
/>
),
isSelected: section === 'settings',
href: getHref('settings'),
'data-test-subj': 'fleet-settings-tab',
},
// the filtering below is needed only for agentTamperProtectionEnabled feature flag
].filter(({ isHidden }) => !isHidden);
return (
<WithHeaderLayout
leftColumn={<DefaultPageTitle />}
rightColumn={rightColumn}
tabs={[
{
name: (
<FormattedMessage
id="xpack.fleet.appNavigation.agentsLinkText"
defaultMessage="Agents"
/>
),
isSelected: section === 'agents',
href: getHref('agent_list'),
disabled: !agents?.enabled,
'data-test-subj': 'fleet-agents-tab',
},
{
name: (
<FormattedMessage
id="xpack.fleet.appNavigation.policiesLinkText"
defaultMessage="Agent policies"
/>
),
isSelected: section === 'agent_policies',
href: getHref('policies_list'),
'data-test-subj': 'fleet-agent-policies-tab',
},
{
name: (
<FormattedMessage
id="xpack.fleet.appNavigation.enrollmentTokensText"
defaultMessage="Enrollment tokens"
/>
),
isSelected: section === 'enrollment_tokens',
href: getHref('enrollment_tokens'),
'data-test-subj': 'fleet-enrollment-tokens-tab',
},
{
name: (
<FormattedMessage
id="xpack.fleet.appNavigation.dataStreamsLinkText"
defaultMessage="Data streams"
/>
),
isSelected: section === 'data_streams',
href: getHref('data_streams'),
'data-test-subj': 'fleet-datastreams-tab',
},
{
name: (
<FormattedMessage
id="xpack.fleet.appNavigation.settingsLinkText"
defaultMessage="Settings"
/>
),
isSelected: section === 'settings',
href: getHref('settings'),
'data-test-subj': 'fleet-settings-tab',
},
]}
>
<WithHeaderLayout leftColumn={<DefaultPageTitle />} rightColumn={rightColumn} tabs={tabs}>
{children}
</WithHeaderLayout>
);

View file

@ -20,7 +20,11 @@ import {
EuiText,
} from '@elastic/eui';
import { FormattedMessage, FormattedDate } from '@kbn/i18n-react';
import type { SendRequestResponse } from '@kbn/es-ui-shared-plugin/public/request/send_request';
import { ApiKeyField } from '../../../../../components/api_key_field';
import type { GetOneEnrollmentAPIKeyResponse } from '../../../../../../common/types';
import {
ENROLLMENT_API_KEYS_INDEX,
SO_SEARCH_LIMIT,
@ -42,73 +46,6 @@ import { DefaultLayout } from '../../../layouts';
import { ConfirmEnrollmentTokenDelete } from './components/confirm_delete_modal';
const ApiKeyField: React.FunctionComponent<{ apiKeyId: string }> = ({ apiKeyId }) => {
const { notifications } = useStartServices();
const [state, setState] = useState<'VISIBLE' | 'HIDDEN' | 'LOADING'>('HIDDEN');
const [key, setKey] = useState<string | undefined>();
const toggleKey = async () => {
if (state === 'VISIBLE') {
setState('HIDDEN');
} else if (state === 'HIDDEN') {
try {
setState('LOADING');
const res = await sendGetOneEnrollmentAPIKey(apiKeyId);
if (res.error) {
throw res.error;
}
setKey(res.data?.item.api_key);
setState('VISIBLE');
} catch (err) {
notifications.toasts.addError(err as Error, {
title: 'Error',
});
setState('HIDDEN');
}
}
};
return (
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem>
<EuiText color="subdued" size="xs">
{state === 'VISIBLE'
? key
: '•••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••'}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
content={
state === 'VISIBLE'
? i18n.translate('xpack.fleet.enrollmentTokensList.hideTokenButtonLabel', {
defaultMessage: 'Hide token',
})
: i18n.translate('xpack.fleet.enrollmentTokensList.showTokenButtonLabel', {
defaultMessage: 'Show token',
})
}
>
<EuiButtonIcon
aria-label={
state === 'VISIBLE'
? i18n.translate('xpack.fleet.enrollmentTokensList.hideTokenButtonLabel', {
defaultMessage: 'Hide token',
})
: i18n.translate('xpack.fleet.enrollmentTokensList.showTokenButtonLabel', {
defaultMessage: 'Show token',
})
}
color="text"
onClick={toggleKey}
iconType={state === 'VISIBLE' ? 'eyeClosed' : 'eye'}
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
);
};
const DeleteButton: React.FunctionComponent<{ apiKey: EnrollmentAPIKey; refresh: () => void }> = ({
apiKey,
refresh,
@ -211,7 +148,16 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => {
}),
width: '215px',
render: (apiKeyId: string) => {
return <ApiKeyField apiKeyId={apiKeyId} />;
return (
<ApiKeyField
apiKeyId={apiKeyId}
sendGetAPIKey={sendGetOneEnrollmentAPIKey}
tokenGetter={(response: SendRequestResponse<GetOneEnrollmentAPIKeyResponse>) =>
response.data?.item.api_key
}
length={60}
/>
);
},
},
{

View file

@ -0,0 +1,185 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { UseRequestResponse } from '@kbn/es-ui-shared-plugin/public';
import { waitFor, fireEvent } from '@testing-library/react';
import type {
GetUninstallTokensMetadataResponse,
GetUninstallTokenResponse,
} from '../../../../../../common/types/rest_spec/uninstall_token';
import type { RequestError } from '../../../../../hooks';
import { createFleetTestRendererMock } from '../../../../../mock';
import {
useGetUninstallToken,
useGetUninstallTokens,
sendGetUninstallToken,
} from '../../../../../hooks/use_request/uninstall_tokens';
import type {
UninstallToken,
UninstallTokenMetadata,
} from '../../../../../../common/types/models/uninstall_token';
import { UninstallTokenListPage } from '.';
jest.mock('../../../../../hooks/use_request/uninstall_tokens', () => ({
useGetUninstallToken: jest.fn(),
useGetUninstallTokens: jest.fn(),
sendGetUninstallToken: jest.fn(),
}));
type MockResponseType<DataType> = Pick<
UseRequestResponse<DataType, RequestError>,
'data' | 'error' | 'isLoading'
>;
describe('UninstallTokenList page', () => {
const render = () => {
const renderer = createFleetTestRendererMock();
return renderer.render(<UninstallTokenListPage />);
};
const useGetUninstallTokenMock = useGetUninstallToken as jest.Mock;
const useGetUninstallTokensMock = useGetUninstallTokens as jest.Mock;
const sendGetUninstallTokenMock = sendGetUninstallToken as jest.Mock;
afterEach(() => {
jest.clearAllMocks();
});
describe('when loading tokens', () => {
it('should show loading message', () => {
const getTokensResponseFixture: MockResponseType<GetUninstallTokensMetadataResponse> = {
isLoading: true,
error: null,
};
useGetUninstallTokensMock.mockReturnValue(getTokensResponseFixture);
const renderResult = render();
expect(renderResult.queryByTestId('uninstallTokenListTable')).toBeInTheDocument();
expect(renderResult.queryByText('Loading uninstall tokens...')).toBeInTheDocument();
});
});
describe('when there are no uninstall tokens', () => {
it('should show message when no tokens found', () => {
const getTokensResponseFixture: MockResponseType<GetUninstallTokensMetadataResponse> = {
isLoading: false,
error: null,
data: { items: [], total: 0, page: 1, perPage: 20 },
};
useGetUninstallTokensMock.mockReturnValue(getTokensResponseFixture);
const renderResult = render();
expect(renderResult.queryByTestId('uninstallTokenListTable')).toBeInTheDocument();
expect(renderResult.queryByText('No uninstall tokens found.')).toBeInTheDocument();
});
});
describe('when there are tokens', () => {
const uninstallTokenMetadataFixture1: UninstallTokenMetadata = {
id: 'id-1',
policy_id: 'policy-id-1',
created_at: '2023-06-19T08:47:31.457Z',
};
const uninstallTokenMetadataFixture2: UninstallTokenMetadata = {
id: 'id-2',
policy_id: 'policy-id-2',
created_at: '2023-06-20T08:47:31.457Z',
};
const uninstallTokenFixture: UninstallToken = {
...uninstallTokenMetadataFixture1,
token: '123456789',
};
const getTokensResponseFixture: MockResponseType<GetUninstallTokensMetadataResponse> = {
isLoading: false,
error: null,
data: {
items: [uninstallTokenMetadataFixture1, uninstallTokenMetadataFixture2],
total: 2,
page: 1,
perPage: 20,
},
};
const getTokenResponseFixture: MockResponseType<GetUninstallTokenResponse> = {
error: null,
isLoading: false,
data: { item: uninstallTokenFixture },
};
beforeEach(() => {
useGetUninstallTokensMock.mockReturnValue(getTokensResponseFixture);
});
it('should render table with token', () => {
const renderResult = render();
expect(renderResult.queryByTestId('uninstallTokenListTable')).toBeInTheDocument();
expect(renderResult.queryByText('policy-id-1')).toBeInTheDocument();
});
it('should hide token by default', () => {
const renderResult = render();
expect(renderResult.queryByText(uninstallTokenFixture.token)).not.toBeInTheDocument();
expect(renderResult.queryAllByText('••••••••••••••••••••••••••••••••').length).toBe(2);
});
it('should fetch and show token when clicking on the "Show" button', async () => {
sendGetUninstallTokenMock.mockReturnValue(getTokenResponseFixture);
const renderResult = render();
renderResult.getAllByTestId('showHideTokenButton')[0].click();
await waitFor(() => {
expect(renderResult.queryByText(uninstallTokenFixture.token)).toBeInTheDocument();
});
expect(sendGetUninstallTokenMock).toHaveBeenCalledWith(uninstallTokenMetadataFixture1.id);
});
it('should show flyout for uninstall command when clicking on the "View uninstall command" button', async () => {
useGetUninstallTokenMock.mockReturnValue(getTokenResponseFixture);
const renderResult = render();
renderResult.getAllByTestId('uninstallTokensViewCommandButton')[0].click();
await waitFor(() => {
expect(renderResult.queryByTestId('uninstall-command-flyout')).toBeInTheDocument();
expect(
renderResult.queryByText(`--uninstall-token ${uninstallTokenFixture.token}`, {
exact: false,
})
).toBeInTheDocument();
});
expect(useGetUninstallTokenMock).toHaveBeenCalledWith(uninstallTokenFixture.id);
});
it('should filter by policyID', async () => {
const renderResult = render();
fireEvent.change(renderResult.getByTestId('uninstallTokensPolicyIdSearchInput'), {
target: { value: 'searched policy id' },
});
expect(useGetUninstallTokensMock).toHaveBeenCalledWith({
page: 1,
perPage: 20,
policyId: 'searched policy id',
});
});
});
});

View file

@ -0,0 +1,203 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CriteriaWithPagination, EuiBasicTableColumn } from '@elastic/eui';
import { EuiFieldSearch } from '@elastic/eui';
import { EuiToolTip } from '@elastic/eui';
import { EuiButtonIcon } from '@elastic/eui';
import { EuiSpacer } from '@elastic/eui';
import { EuiBasicTable, EuiText } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import { FormattedDate, FormattedMessage } from '@kbn/i18n-react';
import type { SendRequestResponse } from '@kbn/es-ui-shared-plugin/public';
import { ApiKeyField } from '../../../../../components/api_key_field';
import type { UninstallTokenMetadata } from '../../../../../../common/types/models/uninstall_token';
import {
sendGetUninstallToken,
useGetUninstallTokens,
} from '../../../../../hooks/use_request/uninstall_tokens';
import { useBreadcrumbs, usePagination } from '../../../hooks';
import { DefaultLayout } from '../../../layouts';
import type { GetUninstallTokenResponse } from '../../../../../../common/types/rest_spec/uninstall_token';
import { UninstallCommandFlyout } from '../../../components';
import {
ACTIONS_TITLE,
CREATED_AT_TITLE,
VIEW_UNINSTALL_COMMAND_LABEL,
POLICY_ID_TITLE,
SEARCH_BY_POLICY_ID_PLACEHOLDER,
TOKEN_TITLE,
} from './translations';
const PolicyIdField = ({ policyId }: { policyId: string }) => (
<EuiText
size="s"
className="eui-textTruncate"
title={policyId}
data-test-subj="uninstallTokensPolicyIdField"
>
{policyId}
</EuiText>
);
const ViewUninstallCommandButton = ({ onClick }: { onClick: () => void }) => (
<EuiToolTip content={VIEW_UNINSTALL_COMMAND_LABEL}>
<EuiButtonIcon
data-test-subj="uninstallTokensViewCommandButton"
aria-label={VIEW_UNINSTALL_COMMAND_LABEL}
onClick={onClick}
iconType="inspect"
color="text"
/>
</EuiToolTip>
);
const NoItemsMessage = ({ isLoading }: { isLoading: boolean }) =>
isLoading ? (
<FormattedMessage
id="xpack.fleet.uninstallTokenList.loadingTokensMessage"
defaultMessage="Loading uninstall tokens..."
/>
) : (
<FormattedMessage
id="xpack.fleet.uninstallTokenList.emptyMessage"
defaultMessage="No uninstall tokens found."
/>
);
export const UninstallTokenListPage = () => {
useBreadcrumbs('uninstall_tokens');
const [policyIdSearch, setPolicyIdSearch] = useState<string>('');
const [tokenIdForFlyout, setTokenIdForFlyout] = useState<string | null>(null);
const { pagination, setPagination, pageSizeOptions } = usePagination();
const { isLoading, data } = useGetUninstallTokens({
perPage: pagination.pageSize,
page: pagination.currentPage,
policyId: policyIdSearch,
});
const tokens = data?.items ?? [];
const total = data?.total ?? 0;
const columns: Array<EuiBasicTableColumn<UninstallTokenMetadata>> = useMemo(
() => [
{
field: 'policy_id',
name: POLICY_ID_TITLE,
render: (policyId: string) => <PolicyIdField policyId={policyId} />,
},
{
field: 'created_at',
name: CREATED_AT_TITLE,
width: '130px',
render: (createdAt: string) =>
createdAt ? (
<FormattedDate year="numeric" month="short" day="2-digit" value={createdAt} />
) : null,
},
{
field: 'id',
name: TOKEN_TITLE,
width: '300px',
render: (uninstallTokenId: string) => (
<ApiKeyField
apiKeyId={uninstallTokenId}
sendGetAPIKey={sendGetUninstallToken}
tokenGetter={(response: SendRequestResponse<GetUninstallTokenResponse>) =>
response.data?.item.token
}
length={32}
/>
),
},
{
field: 'actions',
name: ACTIONS_TITLE,
align: 'center',
width: '70px',
render: (_: any, { id }: UninstallTokenMetadata) => (
<ViewUninstallCommandButton onClick={() => setTokenIdForFlyout(id)} />
),
},
],
[]
);
const handleTablePagination = useCallback(
({ page }: CriteriaWithPagination<UninstallTokenMetadata>) => {
setPagination((prevPagination) => ({
...prevPagination,
currentPage: page.index + 1,
pageSize: page.size,
}));
},
[setPagination]
);
const handleSearch = useCallback(
(searchString: string): void => {
setPolicyIdSearch(searchString);
setPagination((prevPagination) => ({ ...prevPagination, currentPage: 1 }));
},
[setPagination]
);
return (
<DefaultLayout section="uninstall_tokens">
{tokenIdForFlyout && (
<UninstallCommandFlyout
onClose={() => setTokenIdForFlyout(null)}
target="agent"
uninstallTokenId={tokenIdForFlyout}
/>
)}
<EuiText color="subdued" size="s">
<FormattedMessage
id="xpack.fleet.uninstallTokenList.pageDescription"
defaultMessage="Uninstall token allows you to get the uninstall command if you need to uninstall the Agent/Endpoint on the Host."
/>
</EuiText>
<EuiSpacer size="m" />
<EuiFieldSearch
onSearch={handleSearch}
incremental
fullWidth
placeholder={SEARCH_BY_POLICY_ID_PLACEHOLDER}
data-test-subj="uninstallTokensPolicyIdSearchInput"
/>
<EuiSpacer size="m" />
<EuiBasicTable<UninstallTokenMetadata>
data-test-subj="uninstallTokenListTable"
items={tokens}
columns={columns}
itemId="id"
loading={isLoading}
pagination={{
pageIndex: pagination.currentPage - 1,
pageSize: pagination.pageSize,
totalItemCount: total,
pageSizeOptions,
}}
onChange={handleTablePagination}
noItemsMessage={<NoItemsMessage isLoading={isLoading} />}
hasActions={true}
/>
<EuiSpacer size="xl" />
</DefaultLayout>
);
};

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const POLICY_ID_TITLE = i18n.translate('xpack.fleet.uninstallTokenList.policyIdTitle', {
defaultMessage: 'Policy ID',
});
export const CREATED_AT_TITLE = i18n.translate('xpack.fleet.uninstallTokenList.createdAtTitle', {
defaultMessage: 'Created at',
});
export const TOKEN_TITLE = i18n.translate('xpack.fleet.uninstallTokenList.tokenTitle', {
defaultMessage: 'Token',
});
export const ACTIONS_TITLE = i18n.translate('xpack.fleet.uninstallTokenList.actionsTitle', {
defaultMessage: 'Actions',
});
export const VIEW_UNINSTALL_COMMAND_LABEL = i18n.translate(
'xpack.fleet.uninstallTokenList.viewUninstallCommandLabel',
{ defaultMessage: 'View uninstall command' }
);
export const SEARCH_BY_POLICY_ID_PLACEHOLDER = i18n.translate(
'xpack.fleet.uninstallTokenList.searchByPolicyPlaceholder',
{ defaultMessage: 'Search by policy ID' }
);

View file

@ -13,5 +13,6 @@ export type Section =
| 'agents'
| 'agent_policies'
| 'enrollment_tokens'
| 'uninstall_tokens'
| 'data_streams'
| 'settings';

View file

@ -0,0 +1,101 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiButtonIcon,
EuiToolTip,
EuiText,
useEuiTheme,
} from '@elastic/eui';
import type { SendRequestResponse } from '@kbn/es-ui-shared-plugin/public';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { useStartServices } from '../hooks';
export const ApiKeyField: React.FunctionComponent<{
apiKeyId: string;
length: number;
sendGetAPIKey: (id: string) => Promise<SendRequestResponse>;
tokenGetter: (response: SendRequestResponse) => string | undefined;
}> = ({ apiKeyId, length, sendGetAPIKey, tokenGetter }) => {
const { euiTheme } = useEuiTheme();
const { notifications } = useStartServices();
const [state, setState] = useState<'VISIBLE' | 'HIDDEN' | 'LOADING'>('HIDDEN');
const [key, setKey] = useState<string | undefined>();
const tokenMask = useMemo(() => '•'.repeat(length), [length]);
const toggleKey = async () => {
if (state === 'VISIBLE') {
setState('HIDDEN');
} else if (state === 'HIDDEN') {
try {
setState('LOADING');
const res = await sendGetAPIKey(apiKeyId);
if (res.error) {
throw res.error;
}
setKey(tokenGetter(res));
setState('VISIBLE');
} catch (err) {
notifications.toasts.addError(err as Error, {
title: 'Error',
});
setState('HIDDEN');
}
}
};
return (
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiText
color="subdued"
size="xs"
css={css`
font-family: ${euiTheme.font.familyCode};
`}
data-test-subj="apiKeyField"
>
{state === 'VISIBLE' ? key : tokenMask}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
content={
state === 'VISIBLE'
? i18n.translate('xpack.fleet.enrollmentTokensList.hideTokenButtonLabel', {
defaultMessage: 'Hide token',
})
: i18n.translate('xpack.fleet.enrollmentTokensList.showTokenButtonLabel', {
defaultMessage: 'Show token',
})
}
>
<EuiButtonIcon
aria-label={
state === 'VISIBLE'
? i18n.translate('xpack.fleet.enrollmentTokensList.hideTokenButtonLabel', {
defaultMessage: 'Hide token',
})
: i18n.translate('xpack.fleet.enrollmentTokensList.showTokenButtonLabel', {
defaultMessage: 'Show token',
})
}
color="text"
onClick={toggleKey}
iconType={state === 'VISIBLE' ? 'eyeClosed' : 'eye'}
data-test-subj="showHideTokenButton"
/>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -19,6 +19,7 @@ import type {
GetUninstallTokenResponse,
} from '../../../common/types/rest_spec/uninstall_token';
import type { TestRenderer } from '../../mock';
import { createFleetTestRendererMock } from '../../mock';
import {
@ -28,8 +29,8 @@ import {
import type { RequestError } from '../../hooks';
import type { UninstallCommandFlyoutProps } from './uninstall_command_flyout';
import { UninstallCommandFlyout } from './uninstall_command_flyout';
import type { UninstallCommandTarget } from './types';
jest.mock('../../hooks/use_request/uninstall_tokens', () => ({
useGetUninstallToken: jest.fn(),
@ -56,15 +57,21 @@ describe('UninstallCommandFlyout', () => {
const useGetUninstallTokensMock = useGetUninstallTokens as jest.Mock;
const useGetUninstallTokenMock = useGetUninstallToken as jest.Mock;
const render = (props: Partial<UninstallCommandFlyoutProps> = {}) => {
const renderer = createFleetTestRendererMock();
let renderer: TestRenderer;
return renderer.render(
<UninstallCommandFlyout onClose={() => {}} target="agent" policyId="policy_id" {...props} />
const render = () =>
renderer.render(
<UninstallCommandFlyout onClose={() => {}} policyId="policy_id" target="agent" />
);
const renderForTarget = (target: UninstallCommandTarget) =>
renderer.render(
<UninstallCommandFlyout onClose={() => {}} policyId="policy_id" target={target} />
);
};
beforeEach(() => {
renderer = createFleetTestRendererMock();
const getTokensResponseFixture: MockResponseType<GetUninstallTokensMetadataResponse> = {
isLoading: false,
error: null,
@ -87,16 +94,20 @@ describe('UninstallCommandFlyout', () => {
useGetUninstallTokenMock.mockReturnValue(getTokenResponseFixture);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('uninstall command targets', () => {
it('renders flyout for Agent', () => {
const renderResult = render({ target: 'agent' });
const renderResult = renderForTarget('agent');
expect(renderResult.queryByText(/Uninstall Elastic Agent on your host/)).toBeInTheDocument();
expect(renderResult.queryByText(/Uninstall Elastic Defend/)).not.toBeInTheDocument();
});
it('renders flyout for Endpoint integration', () => {
const renderResult = render({ target: 'endpoint' });
const renderResult = renderForTarget('endpoint');
expect(renderResult.queryByText(/Uninstall Elastic Defend/)).toBeInTheDocument();
expect(
@ -232,4 +243,28 @@ describe('UninstallCommandFlyout', () => {
expect(renderResult.queryByText(/Unknown error/)).toBeInTheDocument();
});
});
describe('when using either with `policyId` or `uninstallTokenId`', () => {
it('should perform 2 fetches when using with `policyId`', () => {
renderer.render(
<UninstallCommandFlyout onClose={() => {}} policyId="policy_id" target="agent" />
);
expect(useGetUninstallTokensMock).toHaveBeenCalled();
expect(useGetUninstallTokenMock).toHaveBeenCalled();
});
it('should perform only 1 fetch when providing `uninstallTokenId`', () => {
renderer.render(
<UninstallCommandFlyout
onClose={() => {}}
uninstallTokenId="theProvidedTokenId"
target="agent"
/>
);
expect(useGetUninstallTokensMock).not.toHaveBeenCalled();
expect(useGetUninstallTokenMock).toHaveBeenCalledWith('theProvidedTokenId');
});
});
});

View file

@ -106,13 +106,26 @@ const ErrorFetchingUninstallToken = ({ error }: { error: RequestError | null })
const UninstallCommandsByTokenId = ({ uninstallTokenId }: { uninstallTokenId: string }) => {
const { isLoading, error, data } = useGetUninstallToken(uninstallTokenId);
const token = data?.item.token;
const policyId = data?.item.policy_id;
return isLoading ? (
<Loading size="l" />
) : error || !token ? (
<ErrorFetchingUninstallToken error={error} />
) : (
<UninstallCommandsPerPlatform token={token} />
<>
<UninstallCommandsPerPlatform token={token} />
<EuiSpacer size="l" />
<EuiText data-test-subj="uninstall-command-flyout-policy-id-hint">
<FormattedMessage
id="xpack.fleet.agentUninstallCommandFlyout.validForPolicyId"
defaultMessage="Valid for the following agent policy:"
/>{' '}
<EuiCode>{policyId}</EuiCode>
</EuiText>
</>
);
};
@ -129,14 +142,29 @@ const UninstallCommandsByPolicyId = ({ policyId }: { policyId: string }) => {
);
};
export interface UninstallCommandFlyoutProps {
interface BaseProps {
target: UninstallCommandTarget;
policyId: string;
onClose: () => void;
}
interface PropsWithPolicyId extends BaseProps {
policyId: string;
uninstallTokenId?: never;
}
interface PropsWithTokenId extends BaseProps {
uninstallTokenId: string;
policyId?: never;
}
export type UninstallCommandFlyoutProps = PropsWithPolicyId | PropsWithTokenId;
/** Flyout to show uninstall commands.
*
* Provide EITHER `policyId` OR `tokenId` for showing the token.
*/
export const UninstallCommandFlyout: React.FunctionComponent<UninstallCommandFlyoutProps> = ({
policyId,
uninstallTokenId,
onClose,
target,
}) => {
@ -160,17 +188,11 @@ export const UninstallCommandFlyout: React.FunctionComponent<UninstallCommandFly
<EuiSpacer size="l" />
<UninstallCommandsByPolicyId policyId={policyId} />
<EuiSpacer size="l" />
<EuiText data-test-subj="uninstall-command-flyout-policy-id-hint">
<FormattedMessage
id="xpack.fleet.agentUninstallCommandFlyout.validForPolicyId"
defaultMessage="Valid for the following agent policy:"
/>{' '}
<EuiCode>{policyId}</EuiCode>
</EuiText>
{uninstallTokenId ? (
<UninstallCommandsByTokenId uninstallTokenId={uninstallTokenId} />
) : policyId ? (
<UninstallCommandsByPolicyId policyId={policyId} />
) : null}
</EuiFlyoutBody>
</EuiFlyout>
);

View file

@ -14,6 +14,7 @@ export type StaticPage =
| 'policies'
| 'policies_list'
| 'enrollment_tokens'
| 'uninstall_tokens'
| 'data_streams'
| 'settings'
| 'settings_create_outputs'
@ -72,6 +73,7 @@ export const FLEET_ROUTING_PATHS = {
edit_integration: '/policies/:policyId/edit-integration/:packagePolicyId',
upgrade_package_policy: '/policies/:policyId/upgrade-package-policy/:packagePolicyId',
enrollment_tokens: '/enrollment-tokens',
uninstall_tokens: '/uninstall-tokens',
data_streams: '/data-streams',
settings: '/settings',
settings_create_fleet_server_hosts: '/settings/create-fleet-server-hosts',
@ -220,6 +222,7 @@ export const pagePathGetters: {
agent_details_logs: ({ agentId }) => [FLEET_BASE_PATH, `/agents/${agentId}/logs`],
agent_details_diagnostics: ({ agentId }) => [FLEET_BASE_PATH, `/agents/${agentId}/diagnostics`],
enrollment_tokens: () => [FLEET_BASE_PATH, '/enrollment-tokens'],
uninstall_tokens: () => [FLEET_BASE_PATH, FLEET_ROUTING_PATHS.uninstall_tokens],
data_streams: () => [FLEET_BASE_PATH, '/data-streams'],
settings: () => [FLEET_BASE_PATH, FLEET_ROUTING_PATHS.settings],
settings_edit_fleet_server_hosts: ({ itemId }) => [

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import { uninstallTokensRouteService } from '../../../common/services';
import type {
@ -13,28 +15,30 @@ import type {
GetUninstallTokenResponse,
} from '../../../common/types/rest_spec/uninstall_token';
import { useRequest } from './use_request';
import type { RequestError } from './use_request';
import { sendRequest, sendRequestForRq } from './use_request';
export const useGetUninstallTokens = ({
policyId,
page,
perPage,
}: GetUninstallTokensMetadataRequest['query'] = {}) => {
const query: GetUninstallTokensMetadataRequest['query'] = {
policyId,
page,
perPage,
};
return useRequest<GetUninstallTokensMetadataResponse>({
method: 'get',
path: uninstallTokensRouteService.getListPath(),
query,
});
};
export const useGetUninstallTokens = (query: GetUninstallTokensMetadataRequest['query'] = {}) =>
useQuery<GetUninstallTokensMetadataResponse, RequestError>(['useGetUninstallTokens', query], () =>
sendRequestForRq({
method: 'get',
path: uninstallTokensRouteService.getListPath(),
query,
})
);
export const useGetUninstallToken = (uninstallTokenId: string) =>
useRequest<GetUninstallTokenResponse>({
useQuery<GetUninstallTokenResponse, RequestError>(
['useGetUninstallToken', uninstallTokenId],
() =>
sendRequestForRq({
method: 'get',
path: uninstallTokensRouteService.getInfoPath(uninstallTokenId),
})
);
export const sendGetUninstallToken = (uninstallTokenId: string) =>
sendRequest<GetUninstallTokenResponse>({
method: 'get',
path: uninstallTokensRouteService.getInfoPath(uninstallTokenId),
});

View file

@ -39,6 +39,9 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
// define custom kibana server args here
`--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`,
// add feature flags here
`--xpack.fleet.enableExperimental=${JSON.stringify(['agentTamperProtectionEnabled'])}`,
`--logging.loggers=${JSON.stringify([
...getKibanaCliLoggers(xpackFunctionalTestsConfig.get('kbnTestServer.serverArgs')),