diff --git a/x-pack/legacy/plugins/fleet/common/return_types.ts b/x-pack/legacy/plugins/fleet/common/return_types.ts index 36a26388d29e0..9f7f0f95a2e82 100644 --- a/x-pack/legacy/plugins/fleet/common/return_types.ts +++ b/x-pack/legacy/plugins/fleet/common/return_types.ts @@ -77,6 +77,17 @@ export interface ReturnTypeBulkUpsert extends BaseReturnType { }>; } +export interface ReturnTypeBulkUnenroll extends BaseReturnType { + results: Array<{ + id: string; + success: boolean; + action: 'unenrolled'; + error?: { + message: string; + }; + }>; +} + // list export interface ReturnTypeList extends BaseReturnType { list: T[]; diff --git a/x-pack/legacy/plugins/fleet/dev_docs/api/agents_unenroll.md b/x-pack/legacy/plugins/fleet/dev_docs/api/agents_unenroll.md new file mode 100644 index 0000000000000..fbf8122ec70f3 --- /dev/null +++ b/x-pack/legacy/plugins/fleet/dev_docs/api/agents_unenroll.md @@ -0,0 +1,40 @@ +# Enroll Fleet agent API + +Unenroll an agent + +## Request + +`POST /api/fleet/agents/unenroll` + +## Request body + +- `ids` (Optional, string) An list of agent id to unenroll. +- `kuery` (Optional, string) a kibana query to search for agent to unenroll. + +> Note: one and only of this keys should be present: + +## Response code + +`200` Indicates a successful call. + +## Example + +```js +POST /api/fleet/agents/enroll +{ + "ids": ['agent1'], +} +``` + +The API returns the following: + +```js +{ + "results": [{ + "success":true, + "id":"agent1", + "action":"unenrolled" + }], + "success":true +} +``` diff --git a/x-pack/legacy/plugins/fleet/public/components/agent_health.tsx b/x-pack/legacy/plugins/fleet/public/components/agent_health.tsx index 2117cedf52d41..8007934c63dfa 100644 --- a/x-pack/legacy/plugins/fleet/public/components/agent_health.tsx +++ b/x-pack/legacy/plugins/fleet/public/components/agent_health.tsx @@ -28,6 +28,11 @@ const Status = { ), + Inactive: ( + + + + ), Warning: ( @@ -50,21 +55,25 @@ export const AgentHealth: React.SFC = ({ agent }) => { let status: React.ReactElement = Status.Online; - switch (type) { - case AGENT_TYPE_PERMANENT: - if (intervalsSinceLastCheckIn >= 4) { - status = Status.Error; - break; - } - if (intervalsSinceLastCheckIn >= 2) { - status = Status.Warning; - break; - } - case AGENT_TYPE_TEMPORARY: - if (intervalsSinceLastCheckIn >= 3) { - status = Status.Offline; - break; - } + if (!agent.active) { + status = Status.Inactive; + } else { + switch (type) { + case AGENT_TYPE_PERMANENT: + if (intervalsSinceLastCheckIn >= 4) { + status = Status.Error; + break; + } + if (intervalsSinceLastCheckIn >= 2) { + status = Status.Warning; + break; + } + case AGENT_TYPE_TEMPORARY: + if (intervalsSinceLastCheckIn >= 3) { + status = Status.Offline; + break; + } + } } return ( diff --git a/x-pack/legacy/plugins/fleet/public/lib/adapters/agent/memory_agent_adapter.ts b/x-pack/legacy/plugins/fleet/public/lib/adapters/agent/memory_agent_adapter.ts index f4f256532e702..b7712c7921374 100644 --- a/x-pack/legacy/plugins/fleet/public/lib/adapters/agent/memory_agent_adapter.ts +++ b/x-pack/legacy/plugins/fleet/public/lib/adapters/agent/memory_agent_adapter.ts @@ -6,6 +6,7 @@ import { omit } from 'lodash'; import { Agent, AgentEvent } from '../../../../common/types/domain_data'; +import { ReturnTypeBulkUnenroll } from '../../../../common/return_types'; export class AgentAdapter { private memoryDB: Agent[]; @@ -52,4 +53,18 @@ export class AgentAdapter { public async getWithToken(enrollmentToken: string): Promise { return this.memoryDB.map((beat: any) => omit(beat, ['access_token']))[0]; } + + public async unenrollByIds(ids: string[]): Promise { + return { + results: [], + success: true, + }; + } + + public async unenrollByKuery(ids: string): Promise { + return { + results: [], + success: true, + }; + } } diff --git a/x-pack/legacy/plugins/fleet/public/lib/adapters/agent/rest_agent_adapter.ts b/x-pack/legacy/plugins/fleet/public/lib/adapters/agent/rest_agent_adapter.ts index 97ca259364160..cba06def225eb 100644 --- a/x-pack/legacy/plugins/fleet/public/lib/adapters/agent/rest_agent_adapter.ts +++ b/x-pack/legacy/plugins/fleet/public/lib/adapters/agent/rest_agent_adapter.ts @@ -5,7 +5,12 @@ */ import { Agent } from '../../../../common/types/domain_data'; -import { ReturnTypeGet, ReturnTypeList, ReturnTypeUpdate } from '../../../../common/return_types'; +import { + ReturnTypeGet, + ReturnTypeList, + ReturnTypeUpdate, + ReturnTypeBulkUnenroll, +} from '../../../../common/return_types'; import { RestAPIAdapter } from '../rest_api/adapter_types'; import { AgentAdapter } from './memory_agent_adapter'; import { AgentEvent } from '../../../../common/types/domain_data'; @@ -87,4 +92,16 @@ export class RestAgentAdapter extends AgentAdapter { await this.REST.put>(`/api/fleet/agent/${id}`, beatData); return true; } + + public async unenrollByIds(ids: string[]): Promise { + return await this.REST.post(`/api/fleet/agents/unenroll`, { + ids, + }); + } + + public async unenrollByKuery(kuery: string): Promise { + return await this.REST.post(`/api/fleet/agents/unenroll`, { + kuery, + }); + } } diff --git a/x-pack/legacy/plugins/fleet/public/lib/agent.ts b/x-pack/legacy/plugins/fleet/public/lib/agent.ts index 7acb4ca73976f..da0a33f0ca75a 100644 --- a/x-pack/legacy/plugins/fleet/public/lib/agent.ts +++ b/x-pack/legacy/plugins/fleet/public/lib/agent.ts @@ -58,4 +58,8 @@ export class AgentsLib { public update = async (id: string, agentData: Partial): Promise => { return await this.adapter.update(id, agentData); }; + + public unenroll = async (ids: string[]) => { + return await this.adapter.unenrollByIds(ids); + }; } diff --git a/x-pack/legacy/plugins/fleet/public/pages/agent_details/components/details_section.tsx b/x-pack/legacy/plugins/fleet/public/pages/agent_details/components/details_section.tsx index f8d69b898a955..31f785bcafd0f 100644 --- a/x-pack/legacy/plugins/fleet/public/pages/agent_details/components/details_section.tsx +++ b/x-pack/legacy/plugins/fleet/public/pages/agent_details/components/details_section.tsx @@ -6,7 +6,14 @@ import React, { SFC } from 'react'; import { FormattedMessage, FormattedRelative } from '@kbn/i18n/react'; -import { EuiTitle, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiDescriptionList } from '@elastic/eui'; +import { + EuiTitle, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiDescriptionList, + EuiButton, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Agent } from '../../../../common/types/domain_data'; import { AgentHealth } from '../../../components/agent_health'; @@ -37,7 +44,12 @@ function getMetadataTitle(key: string): string { } } -export const AgentDetailSection: SFC<{ agent: Agent }> = ({ agent }) => { +interface Props { + agent: Agent; + unenrollment: { loading: boolean }; + onClickUnenroll: () => void; +} +export const AgentDetailSection: SFC = ({ agent, onClickUnenroll, unenrollment }) => { const mapMetadata = (obj: { [key: string]: string } | undefined) => { return Object.keys(obj || {}).map(key => ({ key, @@ -95,6 +107,16 @@ export const AgentDetailSection: SFC<{ agent: Agent }> = ({ agent }) => { + + + + + + ); }; diff --git a/x-pack/legacy/plugins/fleet/public/pages/agent_details/components/modal_confirm_unenroll.tsx b/x-pack/legacy/plugins/fleet/public/pages/agent_details/components/modal_confirm_unenroll.tsx new file mode 100644 index 0000000000000..3927c2684fc97 --- /dev/null +++ b/x-pack/legacy/plugins/fleet/public/pages/agent_details/components/modal_confirm_unenroll.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { SFC } from 'react'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +interface Props { + onConfirm: () => void; + onCancel: () => void; +} + +export const ModalConfirmUnenroll: SFC = ({ onConfirm, onCancel }) => { + return ( + + + + ); +}; diff --git a/x-pack/legacy/plugins/fleet/public/pages/agent_details/hooks/use_agent.tsx b/x-pack/legacy/plugins/fleet/public/pages/agent_details/hooks/use_agent.tsx new file mode 100644 index 0000000000000..456f174e160ff --- /dev/null +++ b/x-pack/legacy/plugins/fleet/public/pages/agent_details/hooks/use_agent.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { useState, useEffect } from 'react'; +import { AgentsLib } from '../../../lib/agent'; +import { Agent } from '../../../../common/types/domain_data'; + +export function useGetAgent(agents: AgentsLib, id: string) { + const [state, setState] = useState<{ + isLoading: boolean; + agent: Agent | null; + error: Error | null; + }>({ + isLoading: false, + agent: null, + error: null, + }); + + const fetchAgent = async () => { + setState({ + isLoading: true, + agent: null, + error: null, + }); + try { + const agent = await agents.get(id); + setState({ + isLoading: false, + agent, + error: null, + }); + } catch (error) { + setState({ + isLoading: false, + agent: null, + error, + }); + } + }; + useEffect(() => { + fetchAgent(); + }, [id]); + + return { + ...state, + refreshAgent: fetchAgent, + }; +} diff --git a/x-pack/legacy/plugins/fleet/public/pages/agent_details/hooks/use_unenroll.tsx b/x-pack/legacy/plugins/fleet/public/pages/agent_details/hooks/use_unenroll.tsx new file mode 100644 index 0000000000000..6a19a9187ef1f --- /dev/null +++ b/x-pack/legacy/plugins/fleet/public/pages/agent_details/hooks/use_unenroll.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { useState } from 'react'; +import { AgentsLib } from '../../../lib/agent'; + +export function useUnenroll(agents: AgentsLib, refreshAgent: () => Promise, agentId: string) { + const [state, setState] = useState< + | { + confirm: false; + loading: false; + } + | { + confirm: true; + loading: false; + } + | { + confirm: false; + loading: true; + } + >({ + confirm: false, + loading: false, + }); + + return { + state, + showConfirmModal: () => + setState({ + confirm: true, + loading: false, + }), + confirmUnenrollement: async () => { + setState({ + confirm: false, + loading: true, + }); + + await agents.unenroll([agentId]); + + setState({ + confirm: false, + loading: false, + }); + refreshAgent(); + }, + clear: () => { + setState({ + confirm: false, + loading: false, + }); + }, + }; +} diff --git a/x-pack/legacy/plugins/fleet/public/pages/agent_details/index.tsx b/x-pack/legacy/plugins/fleet/public/pages/agent_details/index.tsx index af4c109dd0b6b..fb5e69519a43d 100644 --- a/x-pack/legacy/plugins/fleet/public/pages/agent_details/index.tsx +++ b/x-pack/legacy/plugins/fleet/public/pages/agent_details/index.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect, SFC } from 'react'; +import React, { SFC } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -16,55 +16,15 @@ import { EuiText, } from '@elastic/eui'; import { RouteComponentProps } from 'react-router-dom'; -import { AgentsLib } from '../../lib/agent'; -import { Agent } from '../../../common/types/domain_data'; import { AgentEventsTable } from './components/agent_events_table'; import { Loading } from '../../components/loading'; import { PolicySection } from './components/policy_section'; import { AgentDetailSection } from './components/details_section'; import { AgentMetadataSection } from './components/metadata_section'; import { FrontendLibs } from '../../lib/types'; - -function useGetAgent(agents: AgentsLib, id: string) { - const [state, setState] = useState<{ - isLoading: boolean; - agent: Agent | null; - error: Error | null; - }>({ - isLoading: false, - agent: null, - error: null, - }); - - const fetchAgent = async () => { - setState({ - isLoading: true, - agent: null, - error: null, - }); - try { - const agent = await agents.get(id); - setState({ - isLoading: false, - agent, - error: null, - }); - } catch (error) { - setState({ - isLoading: false, - agent: null, - error, - }); - } - }; - useEffect(() => { - fetchAgent(); - }, [id]); - - return { - ...state, - }; -} +import { ModalConfirmUnenroll } from './components/modal_confirm_unenroll'; +import { useUnenroll } from './hooks/use_unenroll'; +import { useGetAgent } from './hooks/use_agent'; export const Layout: SFC = ({ children }) => ( @@ -84,7 +44,9 @@ export const AgentDetailsPage: SFC = ({ params: { agentId }, }, }) => { - const { agent, isLoading, error } = useGetAgent(libs.agents, agentId); + const { agent, isLoading, error, refreshAgent } = useGetAgent(libs.agents, agentId); + const unenroll = useUnenroll(libs.agents, refreshAgent, agentId); + if (isLoading) { return ; } @@ -120,10 +82,17 @@ export const AgentDetailsPage: SFC = ({ return ( + {unenroll.state.confirm && ( + + )} - + diff --git a/x-pack/legacy/plugins/fleet/scripts/dev_agent/script.ts b/x-pack/legacy/plugins/fleet/scripts/dev_agent/script.ts index 0ad986fa3a7a3..9cf195aba2190 100644 --- a/x-pack/legacy/plugins/fleet/scripts/dev_agent/script.ts +++ b/x-pack/legacy/plugins/fleet/scripts/dev_agent/script.ts @@ -31,7 +31,7 @@ run( throw createFlagError('please provide a single --path flag'); } const kibanaUrl: string = (flags.kibanaUrl as string) || 'http://localhost:5601'; - const agent = await enroll(kibanaUrl, flags.enrollmentToken as string); + const agent = await enroll(kibanaUrl, flags.enrollmentToken as string, log); log.info('Enrolled with sucess', agent); @@ -76,11 +76,17 @@ async function checkin(kibanaURL: string, agent: Agent, log: ToolingLog) { }, }); + if (res.status === 403) { + closing = true; + log.info('Unenrolling agent'); + return; + } + const json = await res.json(); log.info('checkin', json); } -async function enroll(kibanaURL: string, token: string): Promise { +async function enroll(kibanaURL: string, token: string, log: ToolingLog): Promise { const res = await fetch(`${kibanaURL}/api/fleet/agents/enroll`, { method: 'POST', body: JSON.stringify({ @@ -92,7 +98,7 @@ async function enroll(kibanaURL: string, token: string): Promise { system: `${os.type()} ${os.release()}`, memory: os.totalmem(), }, - userProvided: { + user_provided: { dev_agent_version: '0.0.1', region: 'us-east', }, @@ -103,8 +109,13 @@ async function enroll(kibanaURL: string, token: string): Promise { 'kbn-fleet-enrollment-token': token, }, }); - const json = await res.json(); + + if (!json.success) { + log.error(JSON.stringify(json, null, 2)); + throw new Error('unable to enroll'); + } + return { id: json.item.id, access_token: json.item.access_token, diff --git a/x-pack/legacy/plugins/fleet/server/libs/agent.test.ts b/x-pack/legacy/plugins/fleet/server/libs/agent.test.ts index 86d632e85ed50..3c5162d3766d0 100644 --- a/x-pack/legacy/plugins/fleet/server/libs/agent.test.ts +++ b/x-pack/legacy/plugins/fleet/server/libs/agent.test.ts @@ -271,6 +271,24 @@ describe('Agent lib', () => { ).rejects.toThrowError(/Agent not found/); }); + it('should throw is the agent is not active', async () => { + const token = new TokenLib({} as TokensRepository, {} as FrameworkLib); + const policy = new PolicyLib({} as PoliciesRepository); + const agentsRepository = new InMemoryAgentsRepository(); + const agentsEventsRepository = new InMemoryAgentEventsRepository(); + agentsRepository.agents['agent:1'] = ({ + id: 'agent:1', + actions: [], + active: false, + policy_id: 'policy:1', + } as unknown) as Agent; + const agentLib = new AgentLib(agentsRepository, agentsEventsRepository, token, policy); + + await expect(agentLib.checkin(getUser(), 'agent:1', [])).rejects.toThrowError( + /Agent inactive/ + ); + }); + it('should persist new events', async () => { const token = new TokenLib({} as TokensRepository, {} as FrameworkLib); const policy = new PolicyLib({} as PoliciesRepository); @@ -415,6 +433,42 @@ describe('Agent lib', () => { }); }); + describe('unenroll', () => { + it('should set the list of agents as inactive', async () => { + const token = new TokenLib({} as TokensRepository, {} as FrameworkLib); + const agentsRepository = new InMemoryAgentsRepository(); + const agentEventsRepository = new InMemoryAgentEventsRepository(); + agentsRepository.agents['agent:1'] = ({ + id: 'agent:1', + local_metadata: { key: 'local1' }, + user_provided_metadata: { key: 'user1' }, + actions: [], + events: [], + active: true, + policy_id: 'policy:1', + } as unknown) as Agent; + agentsRepository.agents['agent:2'] = ({ + id: 'agent:2', + local_metadata: { key: 'local1' }, + user_provided_metadata: { key: 'user1' }, + actions: [], + events: [], + active: true, + policy_id: 'policy:1', + } as unknown) as Agent; + const policy = new PolicyLib({} as PoliciesRepository); + const agentLib = new AgentLib(agentsRepository, agentEventsRepository, token, policy); + + await agentLib.unenroll(getUser(), ['agent:1', 'agent:2']); + + const refreshAgent1 = (await agentsRepository.getById(getUser(), 'agent:1')) as Agent; + const refreshAgent2 = (await agentsRepository.getById(getUser(), 'agent:2')) as Agent; + + expect(refreshAgent1.active).toBeFalsy(); + expect(refreshAgent2.active).toBeFalsy(); + }); + }); + describe('addAction', () => { it('should throw if the agent do not exists', async () => { const token = new TokenLib({} as TokensRepository, {} as FrameworkLib); diff --git a/x-pack/legacy/plugins/fleet/server/libs/agent.ts b/x-pack/legacy/plugins/fleet/server/libs/agent.ts index cfadba50b6a10..d539d47f4923e 100644 --- a/x-pack/legacy/plugins/fleet/server/libs/agent.ts +++ b/x-pack/legacy/plugins/fleet/server/libs/agent.ts @@ -92,6 +92,32 @@ export class AgentLib { return { ...agent, access_token: accessToken }; } + public async unenroll( + user: FrameworkUser, + ids: string[] + ): Promise }>> { + const response = []; + for (const id of ids) { + try { + await this.agentsRepository.update(user, id, { + active: false, + }); + response.push({ + id, + success: true, + }); + } catch (error) { + response.push({ + id, + error, + success: false, + }); + } + } + + return response; + } + /** * Delete an agent */ @@ -142,10 +168,14 @@ export class AgentLib { ): Promise<{ actions: AgentAction[]; policy: FullPolicyFile }> { const agent = await this.agentsRepository.getById(user, agentId); - if (!agent || !agent.active) { + if (!agent) { throw Boom.notFound('Agent not found or inactive'); } + if (!agent.active) { + throw Boom.forbidden('Agent inactive'); + } + const actions = this._filterActionsForCheckin(agent); const now = new Date().toISOString(); diff --git a/x-pack/legacy/plugins/fleet/server/routes/agents/unenroll.ts b/x-pack/legacy/plugins/fleet/server/routes/agents/unenroll.ts new file mode 100644 index 0000000000000..2933dfe8ae594 --- /dev/null +++ b/x-pack/legacy/plugins/fleet/server/routes/agents/unenroll.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import * as Joi from 'joi'; +import { FrameworkRequest } from '../../adapters/framework/adapter_types'; +import { ReturnTypeBulkUnenroll } from '../../../common/return_types'; +import { FleetServerLib } from '../../libs/types'; + +export const createPOSTAgentsUnenrollRoute = (libs: FleetServerLib) => ({ + method: 'POST', + path: '/api/fleet/agents/unenroll', + config: { + validate: { + payload: Joi.object({ + ids: Joi.array() + .empty(false) + .items(Joi.string()) + .optional(), + kuery: Joi.string().optional(), + }), + }, + }, + handler: async ( + request: FrameworkRequest<{ + payload: { + ids?: string[]; + kuery?: string; + }; + }> + ): Promise => { + const { ids, kuery } = request.payload; + if ((!ids && !kuery) || (ids && kuery)) { + throw Boom.badRequest('You need to specify ids or kuery'); + } + + let toUnenrollIds: string[] = ids || []; + + if (kuery) { + let hasMore = true; + let page = 1; + while (hasMore) { + const response = await libs.agents.list(request.user, undefined, page++, 1000, kuery); + if (response.agents.length === 0) { + hasMore = false; + } + const agentIds = response.agents.filter(a => a.active).map(a => a.id); + toUnenrollIds = toUnenrollIds.concat(agentIds); + } + } + const results = (await libs.agents.unenroll(request.user, toUnenrollIds)).map( + ({ + success, + id, + error, + }): { + success: boolean; + id: string; + action: 'unenrolled'; + error?: { + message: string; + }; + } => { + return { + success, + id, + action: 'unenrolled', + error: error && { + message: error.message, + }, + }; + } + ); + + return { + results, + success: results.every(result => result.success), + }; + }, +}); diff --git a/x-pack/legacy/plugins/fleet/server/routes/init_api.ts b/x-pack/legacy/plugins/fleet/server/routes/init_api.ts index 3a09118abfaf8..aba8093af5573 100644 --- a/x-pack/legacy/plugins/fleet/server/routes/init_api.ts +++ b/x-pack/legacy/plugins/fleet/server/routes/init_api.ts @@ -22,6 +22,7 @@ import { createGETArtifactsRoute } from './artifacts'; import { createGETAgentEventsRoute } from './agents/events'; import { createGETInstallScript } from './install'; import { createGETAgentsRoute } from './agents/get'; +import { createPOSTAgentsUnenrollRoute } from './agents/unenroll'; export function initRestApi(server: Server, libs: FleetServerLib) { const frameworkAdapter = new HapiFrameworkAdapter(server); @@ -39,6 +40,7 @@ function createAgentsRoutes(adapter: HapiFrameworkAdapter, libs: FleetServerLib) adapter.registerRoute(createGETAgentsRoute(libs)); adapter.registerRoute(createDeleteAgentsRoute(libs)); adapter.registerRoute(createEnrollAgentsRoute(libs)); + adapter.registerRoute(createPOSTAgentsUnenrollRoute(libs)); adapter.registerRoute(createCheckinAgentsRoute(libs)); adapter.registerRoute(createAgentsAddActionRoute(libs)); adapter.registerRoute(createGETAgentEventsRoute(libs)); diff --git a/x-pack/test/api_integration/apis/fleet/index.js b/x-pack/test/api_integration/apis/fleet/index.js index 72673b169b83f..722ae4fc3f645 100644 --- a/x-pack/test/api_integration/apis/fleet/index.js +++ b/x-pack/test/api_integration/apis/fleet/index.js @@ -9,6 +9,7 @@ export default function loadTests({ loadTestFile }) { loadTestFile(require.resolve('./delete_agent')); loadTestFile(require.resolve('./list_agent')); loadTestFile(require.resolve('./enroll_agent')); + loadTestFile(require.resolve('./unenroll_agent')); loadTestFile(require.resolve('./agent_checkin')); loadTestFile(require.resolve('./agent_actions')); loadTestFile(require.resolve('./agent_events')); diff --git a/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts b/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts new file mode 100644 index 0000000000000..e2b6c2072ee4a --- /dev/null +++ b/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertest'); + + describe('fleet_unenroll_agent', () => { + before(async () => { + await esArchiver.loadIfNeeded('fleet/agents'); + }); + after(async () => { + await esArchiver.unload('fleet/agents'); + }); + + it('should not allow both ids and kuery in the payload', async () => { + await supertest + .post(`/api/fleet/agents/unenroll`) + .set('kbn-xsrf', 'xxx') + .send({ + ids: ['agent:1'], + kuery: ['agents.id:1'], + }) + .expect(400); + }); + + it('should not allow no ids or kuery in the payload', async () => { + await supertest + .post(`/api/fleet/agents/unenroll`) + .set('kbn-xsrf', 'xxx') + .send({}) + .expect(400); + }); + + it('allow to unenroll using a list of ids', async () => { + const { body } = await supertest + .post(`/api/fleet/agents/unenroll`) + .set('kbn-xsrf', 'xxx') + .send({ + ids: ['agent1'], + }) + .expect(200); + + expect(body).to.have.keys('results', 'success'); + expect(body.success).to.be(true); + expect(body.results).to.have.length(1); + expect(body.results[0].success).to.be(true); + }); + + it('allow to unenroll using a kibana query', async () => { + const { body } = await supertest + .post(`/api/fleet/agents/unenroll`) + .set('kbn-xsrf', 'xxx') + .send({ + kuery: 'agents.shared_id:agent2_filebeat OR agents.shared_id:agent3_metricbeat', + }) + .expect(200); + + expect(body).to.have.keys('results', 'success'); + expect(body.success).to.be(true); + expect(body.results).to.have.length(2); + expect(body.results[0].success).to.be(true); + + const agentsUnenrolledIds = body.results.map((r: { id: string }) => r.id); + + expect(agentsUnenrolledIds).to.contain('agent2'); + expect(agentsUnenrolledIds).to.contain('agent3'); + }); + }); +}