diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index 2fee3e4c39d1d..2dc4f49919ef7 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -177,7 +177,7 @@ export interface ResolverPaginatedEvents { } /** - * Returned by the server via /api/endpoint/metadata + * Returned by the server via POST /api/endpoint/metadata */ export interface HostResultList { /* the hosts restricted by the page size */ @@ -1231,3 +1231,22 @@ export interface ListPageRouteState { /** The label for the button */ backButtonLabel?: string; } + +/** + * REST API standard base response for list types + */ +export interface BaseListResponse { + data: unknown[]; + page: number; + pageSize: number; + total: number; + sort?: string; + sortOrder?: 'asc' | 'desc'; +} + +/** + * Returned by the server via GET /api/endpoint/metadata + */ +export interface MetadataListResponse extends BaseListResponse { + data: HostInfo[]; +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts index 384fde6c7ceac..8b0e5abad228b 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts @@ -11,7 +11,6 @@ import { TypeOf } from '@kbn/config-schema'; import { IKibanaResponse, IScopedClusterClient, - KibanaRequest, KibanaResponseFactory, Logger, RequestHandler, @@ -22,6 +21,7 @@ import { HostMetadata, HostResultList, HostStatus, + MetadataListResponse, } from '../../../../common/endpoint/types'; import type { SecuritySolutionRequestHandlerContext } from '../../../types'; @@ -33,7 +33,11 @@ import { import { Agent, PackagePolicy } from '../../../../../fleet/common/types/models'; import { AgentNotFoundError } from '../../../../../fleet/server'; import { EndpointAppContext, HostListQueryResult } from '../../types'; -import { GetMetadataListRequestSchema, GetMetadataRequestSchema } from './index'; +import { + GetMetadataListRequestSchema, + GetMetadataListRequestSchemaV2, + GetMetadataRequestSchema, +} from './index'; import { findAllUnenrolledAgentIds } from './support/unenroll'; import { getAllEndpointPackagePolicies } from './support/endpoint_package_policies'; import { findAgentIdsByStatus } from './support/agent_status'; @@ -125,33 +129,35 @@ export const getMetadataListRequestHandler = function ( context.core.savedObjects.client ); - body = await legacyListMetadataQuery( - context, - request, - endpointAppContext, - logger, - endpointPolicies - ); + const pagingProperties = await getPagingProperties(request, endpointAppContext); + + body = await legacyListMetadataQuery(context, endpointAppContext, logger, endpointPolicies, { + page: pagingProperties.pageIndex, + pageSize: pagingProperties.pageSize, + kuery: request?.body?.filters?.kql || '', + hostStatuses: request?.body?.filters?.host_status || [], + }); return response.ok({ body }); } // Unified index is installed and being used - perform search using new approach try { const pagingProperties = await getPagingProperties(request, endpointAppContext); - const { data, page, total, pageSize } = await endpointMetadataService.getHostMetadataList( + const { data, total } = await endpointMetadataService.getHostMetadataList( context.core.elasticsearch.client.asCurrentUser, { - page: pagingProperties.pageIndex + 1, + page: pagingProperties.pageIndex, pageSize: pagingProperties.pageSize, - filters: request.body?.filters || {}, + hostStatuses: request.body?.filters.host_status || [], + kuery: request.body?.filters.kql || '', } ); body = { hosts: data, - request_page_index: page - 1, total, - request_page_size: pageSize, + request_page_index: pagingProperties.pageIndex * pagingProperties.pageSize, + request_page_size: pagingProperties.pageSize, }; } catch (error) { return errorHandler(logger, response, error); @@ -161,6 +167,83 @@ export const getMetadataListRequestHandler = function ( }; }; +export function getMetadataListRequestHandlerV2( + endpointAppContext: EndpointAppContext, + logger: Logger +): RequestHandler< + unknown, + TypeOf, + unknown, + SecuritySolutionRequestHandlerContext +> { + return async (context, request, response) => { + const endpointMetadataService = endpointAppContext.service.getEndpointMetadataService(); + if (!endpointMetadataService) { + throw new EndpointError('endpoint metadata service not available'); + } + + let doesUnitedIndexExist = false; + let didUnitedIndexError = false; + let body: MetadataListResponse = { + data: [], + total: 0, + page: 0, + pageSize: 0, + }; + + try { + doesUnitedIndexExist = await endpointMetadataService.doesUnitedIndexExist( + context.core.elasticsearch.client.asCurrentUser + ); + } catch (error) { + // for better UX, try legacy query instead of immediately failing on united index error + didUnitedIndexError = true; + } + + // If no unified Index present, then perform a search using the legacy approach + if (!doesUnitedIndexExist || didUnitedIndexError) { + const endpointPolicies = await getAllEndpointPackagePolicies( + endpointAppContext.service.getPackagePolicyService(), + context.core.savedObjects.client + ); + + const legacyResponse = await legacyListMetadataQuery( + context, + endpointAppContext, + logger, + endpointPolicies, + request.query + ); + body = { + data: legacyResponse.hosts, + total: legacyResponse.total, + page: request.query.page, + pageSize: request.query.pageSize, + }; + return response.ok({ body }); + } + + // Unified index is installed and being used - perform search using new approach + try { + const { data, total } = await endpointMetadataService.getHostMetadataList( + context.core.elasticsearch.client.asCurrentUser, + request.query + ); + + body = { + data, + total, + page: request.query.page, + pageSize: request.query.pageSize, + }; + } catch (error) { + return errorHandler(logger, response, error); + } + + return response.ok({ body }); + }; +} + export const getMetadataRequestHandler = function ( endpointAppContext: EndpointAppContext, logger: Logger @@ -420,11 +503,10 @@ export async function enrichHostMetadata( async function legacyListMetadataQuery( context: SecuritySolutionRequestHandlerContext, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - request: KibanaRequest, endpointAppContext: EndpointAppContext, logger: Logger, - endpointPolicies: PackagePolicy[] + endpointPolicies: PackagePolicy[], + queryOptions: TypeOf ): Promise { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const agentService = endpointAppContext.service.getAgentService()!; @@ -447,14 +529,16 @@ async function legacyListMetadataQuery( endpointPolicyIds ); - const statusesToFilter = request?.body?.filters?.host_status ?? []; const statusAgentIds = await findAgentIdsByStatus( agentService, context.core.elasticsearch.client.asCurrentUser, - statusesToFilter + queryOptions.hostStatuses ); - const queryParams = await kibanaRequestToMetadataListESQuery(request, endpointAppContext, { + const queryParams = await kibanaRequestToMetadataListESQuery({ + page: queryOptions.page, + pageSize: queryOptions.pageSize, + kuery: queryOptions.kuery, unenrolledAgentIds, statusAgentIds, }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts index d9c3e6c195307..5ea465aa21799 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts @@ -9,7 +9,12 @@ import { schema } from '@kbn/config-schema'; import { HostStatus } from '../../../../common/endpoint/types'; import { EndpointAppContext } from '../../types'; -import { getLogger, getMetadataListRequestHandler, getMetadataRequestHandler } from './handlers'; +import { + getLogger, + getMetadataListRequestHandler, + getMetadataRequestHandler, + getMetadataListRequestHandlerV2, +} from './handlers'; import type { SecuritySolutionPluginRouter } from '../../../types'; import { HOST_METADATA_GET_ROUTE, @@ -60,27 +65,54 @@ export const GetMetadataListRequestSchema = { ), }; +export const GetMetadataListRequestSchemaV2 = { + query: schema.object({ + page: schema.number({ defaultValue: 0 }), + pageSize: schema.number({ defaultValue: 10, min: 1, max: 10000 }), + kuery: schema.maybe(schema.string()), + hostStatuses: schema.arrayOf( + schema.oneOf([ + schema.literal(HostStatus.HEALTHY.toString()), + schema.literal(HostStatus.OFFLINE.toString()), + schema.literal(HostStatus.UPDATING.toString()), + schema.literal(HostStatus.UNHEALTHY.toString()), + schema.literal(HostStatus.INACTIVE.toString()), + ]), + { defaultValue: [] } + ), + }), +}; + export function registerEndpointRoutes( router: SecuritySolutionPluginRouter, endpointAppContext: EndpointAppContext ) { const logger = getLogger(endpointAppContext); - router.post( + router.get( { - path: `${HOST_METADATA_LIST_ROUTE}`, - validate: GetMetadataListRequestSchema, + path: HOST_METADATA_LIST_ROUTE, + validate: GetMetadataListRequestSchemaV2, options: { authRequired: true, tags: ['access:securitySolution'] }, }, - getMetadataListRequestHandler(endpointAppContext, logger) + getMetadataListRequestHandlerV2(endpointAppContext, logger) ); router.get( { - path: `${HOST_METADATA_GET_ROUTE}`, + path: HOST_METADATA_GET_ROUTE, validate: GetMetadataRequestSchema, options: { authRequired: true, tags: ['access:securitySolution'] }, }, getMetadataRequestHandler(endpointAppContext, logger) ); + + router.post( + { + path: HOST_METADATA_LIST_ROUTE, + validate: GetMetadataListRequestSchema, + options: { authRequired: true, tags: ['access:securitySolution'] }, + }, + getMetadataListRequestHandler(endpointAppContext, logger) + ); } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts index c0be1c7530cb4..c1dfee0252b38 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts @@ -18,7 +18,12 @@ import { loggingSystemMock, savedObjectsClientMock, } from '../../../../../../../src/core/server/mocks'; -import { HostInfo, HostResultList, HostStatus } from '../../../../common/endpoint/types'; +import { + HostInfo, + HostResultList, + HostStatus, + MetadataListResponse, +} from '../../../../common/endpoint/types'; import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; import { registerEndpointRoutes } from './index'; import { @@ -40,6 +45,7 @@ import { } from './support/test_support'; import { PackageService } from '../../../../../fleet/server/services'; import { + HOST_METADATA_GET_ROUTE, HOST_METADATA_LIST_ROUTE, metadataCurrentIndexPattern, metadataTransformPrefix, @@ -64,7 +70,7 @@ class IndexNotFoundException extends Error { } } -describe('test endpoint route', () => { +describe('test endpoint routes', () => { let routerMock: jest.Mocked; let mockResponse: jest.Mocked; let mockClusterClient: ClusterClientMock; @@ -113,552 +119,616 @@ describe('test endpoint route', () => { }); }); - describe('with .metrics-endpoint.metadata_united_default index', () => { - beforeEach(() => { - endpointAppContextService = new EndpointAppContextService(); - mockPackageService = createMockPackageService(); - mockPackageService.getInstallation.mockReturnValue( - Promise.resolve({ - installed_kibana: [], - package_assets: [], - es_index_patterns: {}, - name: '', - version: '', - install_status: 'installed', - install_version: '', - install_started_at: '', - install_source: 'registry', - installed_es: [ - { - id: 'logs-endpoint.events.security', - type: ElasticsearchAssetType.indexTemplate, - }, - { - id: `${metadataTransformPrefix}-0.16.0-dev.0`, - type: ElasticsearchAssetType.transform, - }, - ], - keep_policies_up_to_date: false, - }) - ); - endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract()); - endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); - mockAgentService = startContract.agentService!; - mockAgentPolicyService = startContract.agentPolicyService!; + describe('POST list endpoints route', () => { + describe('with .metrics-endpoint.metadata_united_default index', () => { + beforeEach(() => { + endpointAppContextService = new EndpointAppContextService(); + mockPackageService = createMockPackageService(); + mockPackageService.getInstallation.mockReturnValue( + Promise.resolve({ + installed_kibana: [], + package_assets: [], + es_index_patterns: {}, + name: '', + version: '', + install_status: 'installed', + install_version: '', + install_started_at: '', + install_source: 'registry', + installed_es: [ + { + id: 'logs-endpoint.events.security', + type: ElasticsearchAssetType.indexTemplate, + }, + { + id: `${metadataTransformPrefix}-0.16.0-dev.0`, + type: ElasticsearchAssetType.transform, + }, + ], + keep_policies_up_to_date: false, + }) + ); + endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract()); + endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); + mockAgentService = startContract.agentService!; + mockAgentPolicyService = startContract.agentPolicyService!; - registerEndpointRoutes(routerMock, { - logFactory: loggingSystemMock.create(), - service: endpointAppContextService, - config: () => Promise.resolve(createMockConfig()), - experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental), + registerEndpointRoutes(routerMock, { + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), + experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental), + }); }); - }); - afterEach(() => endpointAppContextService.stop()); + afterEach(() => endpointAppContextService.stop()); - it('should fallback to legacy index if index not found', async () => { - const mockRequest = httpServerMock.createKibanaRequest({}); - const response = legacyMetadataSearchResponseMock( - new EndpointDocGenerator().generateHostMetadata() - ); - (mockScopedClient.asCurrentUser.search as jest.Mock) - .mockImplementationOnce(() => { - throw new IndexNotFoundException(); - }) - .mockImplementationOnce(() => Promise.resolve({ body: response })); - [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) - )!; - mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); - mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); - await routeHandler( - createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), - mockRequest, - mockResponse - ); + it('should fallback to legacy index if index not found', async () => { + const mockRequest = httpServerMock.createKibanaRequest({}); + const response = legacyMetadataSearchResponseMock( + new EndpointDocGenerator().generateHostMetadata() + ); + (mockScopedClient.asCurrentUser.search as jest.Mock) + .mockImplementationOnce(() => { + throw new IndexNotFoundException(); + }) + .mockImplementationOnce(() => Promise.resolve({ body: response })); + [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => + path.startsWith(HOST_METADATA_LIST_ROUTE) + )!; + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); - const esSearchMock = mockScopedClient.asCurrentUser.search; - // should be called twice, united index first, then legacy index - expect(esSearchMock).toHaveBeenCalledTimes(2); - expect(esSearchMock.mock.calls[0][0]?.index).toEqual(METADATA_UNITED_INDEX); - expect(esSearchMock.mock.calls[1][0]?.index).toEqual(metadataCurrentIndexPattern); - expect(routeConfig.options).toEqual({ - authRequired: true, - tags: ['access:securitySolution'], + const esSearchMock = mockScopedClient.asCurrentUser.search; + // should be called twice, united index first, then legacy index + expect(esSearchMock).toHaveBeenCalledTimes(2); + expect(esSearchMock.mock.calls[0][0]?.index).toEqual(METADATA_UNITED_INDEX); + expect(esSearchMock.mock.calls[1][0]?.index).toEqual(metadataCurrentIndexPattern); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.ok).toBeCalled(); + const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; + expect(endpointResultList.hosts.length).toEqual(1); + expect(endpointResultList.total).toEqual(1); + expect(endpointResultList.request_page_index).toEqual(0); + expect(endpointResultList.request_page_size).toEqual(10); }); - expect(mockResponse.ok).toBeCalled(); - const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; - expect(endpointResultList.hosts.length).toEqual(1); - expect(endpointResultList.total).toEqual(1); - expect(endpointResultList.request_page_index).toEqual(0); - expect(endpointResultList.request_page_size).toEqual(10); - }); - it('should return expected metadata', async () => { - const mockRequest = httpServerMock.createKibanaRequest({ - body: { - paging_properties: [ - { - page_size: 10, - }, - { - page_index: 0, - }, - ], + it('should return expected metadata', async () => { + const mockRequest = httpServerMock.createKibanaRequest({ + body: { + paging_properties: [ + { + page_size: 10, + }, + { + page_index: 0, + }, + ], - filters: { - kql: 'not host.ip:10.140.73.246', - host_status: ['updating'], + filters: { + kql: 'not host.ip:10.140.73.246', + host_status: ['updating'], + }, }, - }, - }); + }); - mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); - mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); - mockAgentPolicyService.getByIds = jest.fn().mockResolvedValueOnce([]); - const metadata = new EndpointDocGenerator().generateHostMetadata(); - const esSearchMock = mockScopedClient.asCurrentUser.search as jest.Mock; - esSearchMock.mockResolvedValueOnce({}); - esSearchMock.mockResolvedValueOnce({ - body: unitedMetadataSearchResponseMock(metadata), - }); - [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) - )!; + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); + mockAgentPolicyService.getByIds = jest.fn().mockResolvedValueOnce([]); + const metadata = new EndpointDocGenerator().generateHostMetadata(); + const esSearchMock = mockScopedClient.asCurrentUser.search as jest.Mock; + esSearchMock.mockResolvedValueOnce({}); + esSearchMock.mockResolvedValueOnce({ + body: unitedMetadataSearchResponseMock(metadata), + }); + [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => + path.startsWith(HOST_METADATA_LIST_ROUTE) + )!; - await routeHandler( - createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), - mockRequest, - mockResponse - ); + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); - expect(esSearchMock).toHaveBeenCalledTimes(2); - expect(esSearchMock.mock.calls[0][0]?.index).toEqual(METADATA_UNITED_INDEX); - expect(esSearchMock.mock.calls[0][0]?.size).toEqual(1); - expect(esSearchMock.mock.calls[1][0]?.index).toEqual(METADATA_UNITED_INDEX); - expect(esSearchMock.mock.calls[1][0]?.body?.query).toEqual({ - bool: { - must: [ - { - bool: { - filter: [ - { - terms: { - 'united.agent.policy_id': [], + expect(esSearchMock).toHaveBeenCalledTimes(2); + expect(esSearchMock.mock.calls[0][0]?.index).toEqual(METADATA_UNITED_INDEX); + expect(esSearchMock.mock.calls[0][0]?.size).toEqual(1); + expect(esSearchMock.mock.calls[1][0]?.index).toEqual(METADATA_UNITED_INDEX); + expect(esSearchMock.mock.calls[1][0]?.body?.query).toEqual({ + bool: { + must: [ + { + bool: { + filter: [ + { + terms: { + 'united.agent.policy_id': [], + }, }, - }, - { - exists: { - field: 'united.endpoint.agent.id', + { + exists: { + field: 'united.endpoint.agent.id', + }, }, - }, - { - exists: { - field: 'united.agent.agent.id', + { + exists: { + field: 'united.agent.agent.id', + }, }, - }, - { - term: { - 'united.agent.active': { - value: true, + { + term: { + 'united.agent.active': { + value: true, + }, }, }, - }, - ], - must_not: { - terms: { - 'agent.id': [ - '00000000-0000-0000-0000-000000000000', - '11111111-1111-1111-1111-111111111111', - ], + ], + must_not: { + terms: { + 'agent.id': [ + '00000000-0000-0000-0000-000000000000', + '11111111-1111-1111-1111-111111111111', + ], + }, }, }, }, - }, - { - bool: { - should: [ - { - bool: { - filter: [ - { + { + bool: { + should: [ + { + bool: { + filter: [ + { + bool: { + should: [ + { + exists: { + field: 'united.agent.upgrade_started_at', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + must_not: { + bool: { + should: [ + { + exists: { + field: 'united.agent.upgraded_at', + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }, + ], + }, + }, + { + bool: { + must_not: { bool: { should: [ { exists: { - field: 'united.agent.upgrade_started_at', + field: 'united.agent.last_checkin', }, }, ], minimum_should_match: 1, }, }, - { - bool: { - must_not: { - bool: { - should: [ - { - exists: { - field: 'united.agent.upgraded_at', - }, - }, - ], - minimum_should_match: 1, - }, - }, - }, - }, - ], + }, }, - }, - { - bool: { - must_not: { - bool: { - should: [ - { - exists: { - field: 'united.agent.last_checkin', - }, + { + bool: { + should: [ + { + exists: { + field: 'united.agent.unenrollment_started_at', }, - ], - minimum_should_match: 1, - }, + }, + ], + minimum_should_match: 1, }, }, - }, - { + ], + minimum_should_match: 1, + }, + }, + { + bool: { + must_not: { bool: { should: [ { - exists: { - field: 'united.agent.unenrollment_started_at', + match: { + 'host.ip': '10.140.73.246', }, }, ], minimum_should_match: 1, }, }, - ], - minimum_should_match: 1, - }, - }, - { - bool: { - must_not: { - bool: { - should: [ - { - match: { - 'host.ip': '10.140.73.246', - }, - }, - ], - minimum_should_match: 1, - }, }, }, - }, - ], - }, - }); - expect(routeConfig.options).toEqual({ - authRequired: true, - tags: ['access:securitySolution'], + ], + }, + }); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.ok).toBeCalled(); + const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; + expect(endpointResultList.hosts.length).toEqual(1); + expect(endpointResultList.hosts[0].metadata).toEqual(metadata); + expect(endpointResultList.total).toEqual(1); + expect(endpointResultList.request_page_index).toEqual(0); + expect(endpointResultList.request_page_size).toEqual(10); }); - expect(mockResponse.ok).toBeCalled(); - const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; - expect(endpointResultList.hosts.length).toEqual(1); - expect(endpointResultList.hosts[0].metadata).toEqual(metadata); - expect(endpointResultList.total).toEqual(1); - expect(endpointResultList.request_page_index).toEqual(0); - expect(endpointResultList.request_page_size).toEqual(10); }); - }); - describe('with metrics-endpoint.metadata_current_default index', () => { - beforeEach(() => { - endpointAppContextService = new EndpointAppContextService(); - mockPackageService = createMockPackageService(); - mockPackageService.getInstallation.mockReturnValue( - Promise.resolve({ - installed_kibana: [], - package_assets: [], - es_index_patterns: {}, - name: '', - version: '', - install_status: 'installed', - install_version: '', - install_started_at: '', - install_source: 'registry', - installed_es: [ - { - id: 'logs-endpoint.events.security', - type: ElasticsearchAssetType.indexTemplate, - }, - { - id: `${metadataTransformPrefix}-0.16.0-dev.0`, - type: ElasticsearchAssetType.transform, - }, - ], - keep_policies_up_to_date: false, - }) - ); - endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract()); - endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); - mockAgentService = startContract.agentService!; + describe('with metrics-endpoint.metadata_current_default index', () => { + beforeEach(() => { + endpointAppContextService = new EndpointAppContextService(); + mockPackageService = createMockPackageService(); + mockPackageService.getInstallation.mockReturnValue( + Promise.resolve({ + installed_kibana: [], + package_assets: [], + es_index_patterns: {}, + name: '', + version: '', + install_status: 'installed', + install_version: '', + install_started_at: '', + install_source: 'registry', + installed_es: [ + { + id: 'logs-endpoint.events.security', + type: ElasticsearchAssetType.indexTemplate, + }, + { + id: `${metadataTransformPrefix}-0.16.0-dev.0`, + type: ElasticsearchAssetType.transform, + }, + ], + keep_policies_up_to_date: false, + }) + ); + endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract()); + endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); + mockAgentService = startContract.agentService!; - registerEndpointRoutes(routerMock, { - logFactory: loggingSystemMock.create(), - service: endpointAppContextService, - config: () => Promise.resolve(createMockConfig()), - experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental), + registerEndpointRoutes(routerMock, { + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), + experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental), + }); }); - }); - afterEach(() => endpointAppContextService.stop()); + afterEach(() => endpointAppContextService.stop()); - it('test find the latest of all endpoints', async () => { - const mockRequest = httpServerMock.createKibanaRequest({}); - const response = legacyMetadataSearchResponseMock( - new EndpointDocGenerator().generateHostMetadata() - ); - (mockScopedClient.asCurrentUser.search as jest.Mock) - .mockImplementationOnce(() => { - throw new IndexNotFoundException(); - }) - .mockImplementationOnce(() => Promise.resolve({ body: response })); - [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) - )!; - mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); - mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); - await routeHandler( - createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), - mockRequest, - mockResponse - ); + it('test find the latest of all endpoints', async () => { + const mockRequest = httpServerMock.createKibanaRequest({}); + const response = legacyMetadataSearchResponseMock( + new EndpointDocGenerator().generateHostMetadata() + ); + (mockScopedClient.asCurrentUser.search as jest.Mock) + .mockImplementationOnce(() => { + throw new IndexNotFoundException(); + }) + .mockImplementationOnce(() => Promise.resolve({ body: response })); + [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => + path.startsWith(HOST_METADATA_LIST_ROUTE) + )!; + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); - expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(2); - expect(routeConfig.options).toEqual({ - authRequired: true, - tags: ['access:securitySolution'], - }); - expect(mockResponse.ok).toBeCalled(); - const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; - expect(endpointResultList.hosts.length).toEqual(1); - expect(endpointResultList.total).toEqual(1); - expect(endpointResultList.request_page_index).toEqual(0); - expect(endpointResultList.request_page_size).toEqual(10); - }); - - it('test find the latest of all endpoints with paging properties', async () => { - const mockRequest = httpServerMock.createKibanaRequest({ - body: { - paging_properties: [ - { - page_size: 10, - }, - { - page_index: 1, - }, - ], - }, + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(2); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.ok).toBeCalled(); + const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; + expect(endpointResultList.hosts.length).toEqual(1); + expect(endpointResultList.total).toEqual(1); + expect(endpointResultList.request_page_index).toEqual(0); + expect(endpointResultList.request_page_size).toEqual(10); }); - mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); - mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); - (mockScopedClient.asCurrentUser.search as jest.Mock) - .mockImplementationOnce(() => { - throw new IndexNotFoundException(); - }) - .mockImplementationOnce(() => - Promise.resolve({ - body: legacyMetadataSearchResponseMock( - new EndpointDocGenerator().generateHostMetadata() - ), + it('test find the latest of all endpoints with paging properties', async () => { + const mockRequest = httpServerMock.createKibanaRequest({ + body: { + paging_properties: [ + { + page_size: 10, + }, + { + page_index: 1, + }, + ], + }, + }); + + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); + (mockScopedClient.asCurrentUser.search as jest.Mock) + .mockImplementationOnce(() => { + throw new IndexNotFoundException(); }) - ); - [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) - )!; + .mockImplementationOnce(() => + Promise.resolve({ + body: legacyMetadataSearchResponseMock( + new EndpointDocGenerator().generateHostMetadata() + ), + }) + ); + [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => + path.startsWith(HOST_METADATA_LIST_ROUTE) + )!; - await routeHandler( - createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), - mockRequest, - mockResponse - ); - expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(2); - expect( - (mockScopedClient.asCurrentUser.search as jest.Mock).mock.calls[1][0]?.body?.query.bool - .must_not - ).toContainEqual({ - terms: { - 'elastic.agent.id': [ - '00000000-0000-0000-0000-000000000000', - '11111111-1111-1111-1111-111111111111', - ], - }, - }); - expect(routeConfig.options).toEqual({ - authRequired: true, - tags: ['access:securitySolution'], + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(2); + expect( + (mockScopedClient.asCurrentUser.search as jest.Mock).mock.calls[1][0]?.body?.query.bool + .must_not + ).toContainEqual({ + terms: { + 'elastic.agent.id': [ + '00000000-0000-0000-0000-000000000000', + '11111111-1111-1111-1111-111111111111', + ], + }, + }); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.ok).toBeCalled(); + const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; + expect(endpointResultList.hosts.length).toEqual(1); + expect(endpointResultList.total).toEqual(1); + expect(endpointResultList.request_page_index).toEqual(10); + expect(endpointResultList.request_page_size).toEqual(10); }); - expect(mockResponse.ok).toBeCalled(); - const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; - expect(endpointResultList.hosts.length).toEqual(1); - expect(endpointResultList.total).toEqual(1); - expect(endpointResultList.request_page_index).toEqual(10); - expect(endpointResultList.request_page_size).toEqual(10); - }); - it('test find the latest of all endpoints with paging and filters properties', async () => { - const mockRequest = httpServerMock.createKibanaRequest({ - body: { - paging_properties: [ - { - page_size: 10, - }, - { - page_index: 1, - }, - ], + it('test find the latest of all endpoints with paging and filters properties', async () => { + const mockRequest = httpServerMock.createKibanaRequest({ + body: { + paging_properties: [ + { + page_size: 10, + }, + { + page_index: 1, + }, + ], - filters: { kql: 'not host.ip:10.140.73.246' }, - }, - }); + filters: { kql: 'not host.ip:10.140.73.246' }, + }, + }); - mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); - mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); - (mockScopedClient.asCurrentUser.search as jest.Mock) - .mockImplementationOnce(() => { - throw new IndexNotFoundException(); - }) - .mockImplementationOnce(() => - Promise.resolve({ - body: legacyMetadataSearchResponseMock( - new EndpointDocGenerator().generateHostMetadata() - ), + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); + (mockScopedClient.asCurrentUser.search as jest.Mock) + .mockImplementationOnce(() => { + throw new IndexNotFoundException(); }) - ); - [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => - path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) - )!; + .mockImplementationOnce(() => + Promise.resolve({ + body: legacyMetadataSearchResponseMock( + new EndpointDocGenerator().generateHostMetadata() + ), + }) + ); + [routeConfig, routeHandler] = routerMock.post.mock.calls.find(([{ path }]) => + path.startsWith(HOST_METADATA_LIST_ROUTE) + )!; - await routeHandler( - createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), - mockRequest, - mockResponse - ); + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); - expect(mockScopedClient.asCurrentUser.search).toBeCalled(); - expect( - // KQL filter to be passed through - (mockScopedClient.asCurrentUser.search as jest.Mock).mock.calls[1][0]?.body?.query.bool.must - ).toContainEqual({ - bool: { - must_not: { - bool: { - should: [ - { - match: { - 'host.ip': '10.140.73.246', + expect(mockScopedClient.asCurrentUser.search).toBeCalled(); + expect( + // KQL filter to be passed through + (mockScopedClient.asCurrentUser.search as jest.Mock).mock.calls[1][0]?.body?.query.bool + .must + ).toContainEqual({ + bool: { + must_not: { + bool: { + should: [ + { + match: { + 'host.ip': '10.140.73.246', + }, }, - }, - ], - minimum_should_match: 1, - }, - }, - }, - }); - expect( - (mockScopedClient.asCurrentUser.search as jest.Mock).mock.calls[1][0]?.body?.query.bool.must - ).toContainEqual({ - bool: { - must_not: [ - { - terms: { - 'elastic.agent.id': [ - '00000000-0000-0000-0000-000000000000', - '11111111-1111-1111-1111-111111111111', ], + minimum_should_match: 1, }, }, - { - terms: { - // here we DO want to see both schemas are present - // to make this schema-compatible forward and back - 'HostDetails.elastic.agent.id': [ - '00000000-0000-0000-0000-000000000000', - '11111111-1111-1111-1111-111111111111', - ], + }, + }); + expect( + (mockScopedClient.asCurrentUser.search as jest.Mock).mock.calls[1][0]?.body?.query.bool + .must + ).toContainEqual({ + bool: { + must_not: [ + { + terms: { + 'elastic.agent.id': [ + '00000000-0000-0000-0000-000000000000', + '11111111-1111-1111-1111-111111111111', + ], + }, }, - }, - ], - }, - }); - expect(routeConfig.options).toEqual({ - authRequired: true, - tags: ['access:securitySolution'], + { + terms: { + // here we DO want to see both schemas are present + // to make this schema-compatible forward and back + 'HostDetails.elastic.agent.id': [ + '00000000-0000-0000-0000-000000000000', + '11111111-1111-1111-1111-111111111111', + ], + }, + }, + ], + }, + }); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.ok).toBeCalled(); + const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; + expect(endpointResultList.hosts.length).toEqual(1); + expect(endpointResultList.total).toEqual(1); + expect(endpointResultList.request_page_index).toEqual(10); + expect(endpointResultList.request_page_size).toEqual(10); }); - expect(mockResponse.ok).toBeCalled(); - const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as HostResultList; - expect(endpointResultList.hosts.length).toEqual(1); - expect(endpointResultList.total).toEqual(1); - expect(endpointResultList.request_page_index).toEqual(10); - expect(endpointResultList.request_page_size).toEqual(10); }); + }); - describe('Endpoint Details route', () => { - it('should return 404 on no results', async () => { - const mockRequest = httpServerMock.createKibanaRequest({ params: { id: 'BADID' } }); - - (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => - Promise.resolve({ body: legacyMetadataSearchResponseMock() }) + describe('GET list endpoints route', () => { + describe('with .metrics-endpoint.metadata_united_default index', () => { + beforeEach(() => { + endpointAppContextService = new EndpointAppContextService(); + mockPackageService = createMockPackageService(); + mockPackageService.getInstallation.mockReturnValue( + Promise.resolve({ + installed_kibana: [], + package_assets: [], + es_index_patterns: {}, + name: '', + version: '', + install_status: 'installed', + install_version: '', + install_started_at: '', + install_source: 'registry', + installed_es: [ + { + id: 'logs-endpoint.events.security', + type: ElasticsearchAssetType.indexTemplate, + }, + { + id: `${metadataTransformPrefix}-0.16.0-dev.0`, + type: ElasticsearchAssetType.transform, + }, + ], + keep_policies_up_to_date: false, + }) ); + endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract()); + endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); + mockAgentService = startContract.agentService!; + mockAgentPolicyService = startContract.agentPolicyService!; - mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); - mockAgentService.getAgent = jest.fn().mockReturnValue({ - active: true, - } as unknown as Agent); + registerEndpointRoutes(routerMock, { + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), + experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental), + }); + }); + + afterEach(() => endpointAppContextService.stop()); + it('should fallback to legacy index if index not found', async () => { + const mockRequest = httpServerMock.createKibanaRequest({ + query: { + page: 0, + pageSize: 10, + }, + }); + const response = legacyMetadataSearchResponseMock( + new EndpointDocGenerator().generateHostMetadata() + ); + (mockScopedClient.asCurrentUser.search as jest.Mock) + .mockImplementationOnce(() => { + throw new IndexNotFoundException(); + }) + .mockImplementationOnce(() => Promise.resolve({ body: response })); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) + path.startsWith(HOST_METADATA_LIST_ROUTE) )!; + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); await routeHandler( createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), mockRequest, mockResponse ); - expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); + const esSearchMock = mockScopedClient.asCurrentUser.search; + // should be called twice, united index first, then legacy index + expect(esSearchMock).toHaveBeenCalledTimes(2); + expect(esSearchMock.mock.calls[0][0]?.index).toEqual(METADATA_UNITED_INDEX); + expect(esSearchMock.mock.calls[1][0]?.index).toEqual(metadataCurrentIndexPattern); expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'], }); - expect(mockResponse.notFound).toBeCalled(); - const message = mockResponse.notFound.mock.calls[0][0]?.body; - expect(message).toBeInstanceOf(EndpointHostNotFoundError); + expect(mockResponse.ok).toBeCalled(); + const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as MetadataListResponse; + expect(endpointResultList.data.length).toEqual(1); + expect(endpointResultList.total).toEqual(1); + expect(endpointResultList.page).toEqual(0); + expect(endpointResultList.pageSize).toEqual(10); }); - it('should return a single endpoint with status healthy', async () => { - const response = legacyMetadataSearchResponseMock( - new EndpointDocGenerator().generateHostMetadata() - ); + it('should return expected metadata', async () => { const mockRequest = httpServerMock.createKibanaRequest({ - params: { id: response.hits.hits[0]._id }, + query: { + page: 0, + pageSize: 10, + hostStatuses: ['updating'], + kuery: 'not host.ip:10.140.73.246', + }, }); - mockAgentService.getAgent = jest - .fn() - .mockReturnValue(agentGenerator.generate({ status: 'online' })); - (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => - Promise.resolve({ body: response }) - ); - + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); + mockAgentPolicyService.getByIds = jest.fn().mockResolvedValueOnce([]); + const metadata = new EndpointDocGenerator().generateHostMetadata(); + const esSearchMock = mockScopedClient.asCurrentUser.search as jest.Mock; + esSearchMock.mockResolvedValueOnce({}); + esSearchMock.mockResolvedValueOnce({ + body: unitedMetadataSearchResponseMock(metadata), + }); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) + path.startsWith(HOST_METADATA_LIST_ROUTE) )!; await routeHandler( @@ -667,74 +737,255 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); + expect(esSearchMock).toHaveBeenCalledTimes(2); + expect(esSearchMock.mock.calls[0][0]?.index).toEqual(METADATA_UNITED_INDEX); + expect(esSearchMock.mock.calls[0][0]?.size).toEqual(1); + expect(esSearchMock.mock.calls[1][0]?.index).toEqual(METADATA_UNITED_INDEX); + expect(esSearchMock.mock.calls[1][0]?.body?.query).toEqual({ + bool: { + must: [ + { + bool: { + filter: [ + { + terms: { + 'united.agent.policy_id': [], + }, + }, + { + exists: { + field: 'united.endpoint.agent.id', + }, + }, + { + exists: { + field: 'united.agent.agent.id', + }, + }, + { + term: { + 'united.agent.active': { + value: true, + }, + }, + }, + ], + must_not: { + terms: { + 'agent.id': [ + '00000000-0000-0000-0000-000000000000', + '11111111-1111-1111-1111-111111111111', + ], + }, + }, + }, + }, + { + bool: { + should: [ + { + bool: { + filter: [ + { + bool: { + should: [ + { + exists: { + field: 'united.agent.upgrade_started_at', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + must_not: { + bool: { + should: [ + { + exists: { + field: 'united.agent.upgraded_at', + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }, + ], + }, + }, + { + bool: { + must_not: { + bool: { + should: [ + { + exists: { + field: 'united.agent.last_checkin', + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }, + { + bool: { + should: [ + { + exists: { + field: 'united.agent.unenrollment_started_at', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + must_not: { + bool: { + should: [ + { + match: { + 'host.ip': '10.140.73.246', + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }, + ], + }, + }); expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'], }); expect(mockResponse.ok).toBeCalled(); - const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; - expect(result).toHaveProperty('metadata.Endpoint'); - expect(result.host_status).toEqual(HostStatus.HEALTHY); + const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as MetadataListResponse; + expect(endpointResultList.data.length).toEqual(1); + expect(endpointResultList.data[0].metadata).toEqual(metadata); + expect(endpointResultList.total).toEqual(1); + expect(endpointResultList.page).toEqual(0); + expect(endpointResultList.pageSize).toEqual(10); }); + }); - it('should return a single endpoint with status unhealthy when AgentService throw 404', async () => { - const response = legacyMetadataSearchResponseMock( - new EndpointDocGenerator().generateHostMetadata() + describe('with metrics-endpoint.metadata_current_default index', () => { + beforeEach(() => { + endpointAppContextService = new EndpointAppContextService(); + mockPackageService = createMockPackageService(); + mockPackageService.getInstallation.mockReturnValue( + Promise.resolve({ + installed_kibana: [], + package_assets: [], + es_index_patterns: {}, + name: '', + version: '', + install_status: 'installed', + install_version: '', + install_started_at: '', + install_source: 'registry', + installed_es: [ + { + id: 'logs-endpoint.events.security', + type: ElasticsearchAssetType.indexTemplate, + }, + { + id: `${metadataTransformPrefix}-0.16.0-dev.0`, + type: ElasticsearchAssetType.transform, + }, + ], + keep_policies_up_to_date: false, + }) ); + endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract()); + endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); + mockAgentService = startContract.agentService!; - const mockRequest = httpServerMock.createKibanaRequest({ - params: { id: response.hits.hits[0]._id }, + registerEndpointRoutes(routerMock, { + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), + experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental), }); + }); - mockAgentService.getAgent = jest - .fn() - .mockRejectedValue(new AgentNotFoundError('not found')); + afterEach(() => endpointAppContextService.stop()); - (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => - Promise.resolve({ body: response }) + it('test find the latest of all endpoints', async () => { + const mockRequest = httpServerMock.createKibanaRequest({ + query: { + page: 0, + pageSize: 10, + }, + }); + const response = legacyMetadataSearchResponseMock( + new EndpointDocGenerator().generateHostMetadata() ); - + (mockScopedClient.asCurrentUser.search as jest.Mock) + .mockImplementationOnce(() => { + throw new IndexNotFoundException(); + }) + .mockImplementationOnce(() => Promise.resolve({ body: response })); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) + path.startsWith(HOST_METADATA_LIST_ROUTE) )!; - + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); await routeHandler( createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), mockRequest, mockResponse ); - expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(2); expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'], }); expect(mockResponse.ok).toBeCalled(); - const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; - expect(result.host_status).toEqual(HostStatus.UNHEALTHY); + const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as MetadataListResponse; + expect(endpointResultList.data.length).toEqual(1); + expect(endpointResultList.total).toEqual(1); + expect(endpointResultList.page).toEqual(0); + expect(endpointResultList.pageSize).toEqual(10); }); - it('should return a single endpoint with status unhealthy when status is not offline, online or enrolling', async () => { - const response = legacyMetadataSearchResponseMock( - new EndpointDocGenerator().generateHostMetadata() - ); - + it('test find the latest of all endpoints with paging properties', async () => { const mockRequest = httpServerMock.createKibanaRequest({ - params: { id: response.hits.hits[0]._id }, + query: { + page: 1, + pageSize: 10, + }, }); - mockAgentService.getAgent = jest.fn().mockReturnValue( - agentGenerator.generate({ - status: 'error', + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); + (mockScopedClient.asCurrentUser.search as jest.Mock) + .mockImplementationOnce(() => { + throw new IndexNotFoundException(); }) - ); - (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => - Promise.resolve({ body: response }) - ); - + .mockImplementationOnce(() => + Promise.resolve({ + body: legacyMetadataSearchResponseMock( + new EndpointDocGenerator().generateHostMetadata() + ), + }) + ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) + path.startsWith(HOST_METADATA_LIST_ROUTE) )!; await routeHandler( @@ -742,34 +993,54 @@ describe('test endpoint route', () => { mockRequest, mockResponse ); - - expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(2); + expect( + (mockScopedClient.asCurrentUser.search as jest.Mock).mock.calls[1][0]?.body?.query.bool + .must_not + ).toContainEqual({ + terms: { + 'elastic.agent.id': [ + '00000000-0000-0000-0000-000000000000', + '11111111-1111-1111-1111-111111111111', + ], + }, + }); expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'], }); expect(mockResponse.ok).toBeCalled(); - const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; - expect(result.host_status).toEqual(HostStatus.UNHEALTHY); + const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as MetadataListResponse; + expect(endpointResultList.data.length).toEqual(1); + expect(endpointResultList.total).toEqual(1); + expect(endpointResultList.page).toEqual(1); + expect(endpointResultList.pageSize).toEqual(10); }); - it('should throw error when endpoint agent is not active', async () => { - const response = legacyMetadataSearchResponseMock( - new EndpointDocGenerator().generateHostMetadata() - ); - + it('test find the latest of all endpoints with paging and filters properties', async () => { const mockRequest = httpServerMock.createKibanaRequest({ - params: { id: response.hits.hits[0]._id }, + query: { + page: 1, + pageSize: 10, + kuery: 'not host.ip:10.140.73.246', + }, }); - (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => - Promise.resolve({ body: response }) - ); - mockAgentService.getAgent = jest.fn().mockReturnValue({ - active: false, - } as unknown as Agent); + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.listAgents = jest.fn().mockReturnValue(noUnenrolledAgent); + (mockScopedClient.asCurrentUser.search as jest.Mock) + .mockImplementationOnce(() => { + throw new IndexNotFoundException(); + }) + .mockImplementationOnce(() => + Promise.resolve({ + body: legacyMetadataSearchResponseMock( + new EndpointDocGenerator().generateHostMetadata() + ), + }) + ); [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => - path.startsWith(`${HOST_METADATA_LIST_ROUTE}`) + path.startsWith(HOST_METADATA_LIST_ROUTE) )!; await routeHandler( @@ -778,9 +1049,277 @@ describe('test endpoint route', () => { mockResponse ); - expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); - expect(mockResponse.badRequest).toBeCalled(); + expect(mockScopedClient.asCurrentUser.search).toBeCalled(); + expect( + // KQL filter to be passed through + (mockScopedClient.asCurrentUser.search as jest.Mock).mock.calls[1][0]?.body?.query.bool + .must + ).toContainEqual({ + bool: { + must_not: { + bool: { + should: [ + { + match: { + 'host.ip': '10.140.73.246', + }, + }, + ], + minimum_should_match: 1, + }, + }, + }, + }); + expect( + (mockScopedClient.asCurrentUser.search as jest.Mock).mock.calls[1][0]?.body?.query.bool + .must + ).toContainEqual({ + bool: { + must_not: [ + { + terms: { + 'elastic.agent.id': [ + '00000000-0000-0000-0000-000000000000', + '11111111-1111-1111-1111-111111111111', + ], + }, + }, + { + terms: { + // here we DO want to see both schemas are present + // to make this schema-compatible forward and back + 'HostDetails.elastic.agent.id': [ + '00000000-0000-0000-0000-000000000000', + '11111111-1111-1111-1111-111111111111', + ], + }, + }, + ], + }, + }); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.ok).toBeCalled(); + const endpointResultList = mockResponse.ok.mock.calls[0][0]?.body as MetadataListResponse; + expect(endpointResultList.data.length).toEqual(1); + expect(endpointResultList.total).toEqual(1); + expect(endpointResultList.page).toEqual(1); + expect(endpointResultList.pageSize).toEqual(10); + }); + }); + }); + + describe('GET endpoint details route', () => { + beforeEach(() => { + endpointAppContextService = new EndpointAppContextService(); + mockPackageService = createMockPackageService(); + mockPackageService.getInstallation.mockReturnValue( + Promise.resolve({ + installed_kibana: [], + package_assets: [], + es_index_patterns: {}, + name: '', + version: '', + install_status: 'installed', + install_version: '', + install_started_at: '', + install_source: 'registry', + installed_es: [ + { + id: 'logs-endpoint.events.security', + type: ElasticsearchAssetType.indexTemplate, + }, + { + id: `${metadataTransformPrefix}-0.16.0-dev.0`, + type: ElasticsearchAssetType.transform, + }, + ], + keep_policies_up_to_date: false, + }) + ); + endpointAppContextService.setup(createMockEndpointAppContextServiceSetupContract()); + endpointAppContextService.start({ ...startContract, packageService: mockPackageService }); + mockAgentService = startContract.agentService!; + + registerEndpointRoutes(routerMock, { + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), + experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental), + }); + }); + + afterEach(() => endpointAppContextService.stop()); + + it('should return 404 on no results', async () => { + const mockRequest = httpServerMock.createKibanaRequest({ params: { id: 'BADID' } }); + + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: legacyMetadataSearchResponseMock() }) + ); + + mockAgentService.getAgentStatusById = jest.fn().mockReturnValue('error'); + mockAgentService.getAgent = jest.fn().mockReturnValue({ + active: true, + } as unknown as Agent); + + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith(HOST_METADATA_GET_ROUTE) + )!; + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.notFound).toBeCalled(); + const message = mockResponse.notFound.mock.calls[0][0]?.body; + expect(message).toBeInstanceOf(EndpointHostNotFoundError); + }); + + it('should return a single endpoint with status healthy', async () => { + const response = legacyMetadataSearchResponseMock( + new EndpointDocGenerator().generateHostMetadata() + ); + const mockRequest = httpServerMock.createKibanaRequest({ + params: { id: response.hits.hits[0]._id }, }); + + mockAgentService.getAgent = jest + .fn() + .mockReturnValue(agentGenerator.generate({ status: 'online' })); + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: response }) + ); + + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith(HOST_METADATA_GET_ROUTE) + )!; + + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.ok).toBeCalled(); + const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; + expect(result).toHaveProperty('metadata.Endpoint'); + expect(result.host_status).toEqual(HostStatus.HEALTHY); + }); + + it('should return a single endpoint with status unhealthy when AgentService throw 404', async () => { + const response = legacyMetadataSearchResponseMock( + new EndpointDocGenerator().generateHostMetadata() + ); + + const mockRequest = httpServerMock.createKibanaRequest({ + params: { id: response.hits.hits[0]._id }, + }); + + mockAgentService.getAgent = jest.fn().mockRejectedValue(new AgentNotFoundError('not found')); + + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: response }) + ); + + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith(HOST_METADATA_GET_ROUTE) + )!; + + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.ok).toBeCalled(); + const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; + expect(result.host_status).toEqual(HostStatus.UNHEALTHY); + }); + + it('should return a single endpoint with status unhealthy when status is not offline, online or enrolling', async () => { + const response = legacyMetadataSearchResponseMock( + new EndpointDocGenerator().generateHostMetadata() + ); + + const mockRequest = httpServerMock.createKibanaRequest({ + params: { id: response.hits.hits[0]._id }, + }); + + mockAgentService.getAgent = jest.fn().mockReturnValue( + agentGenerator.generate({ + status: 'error', + }) + ); + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: response }) + ); + + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith(HOST_METADATA_GET_ROUTE) + )!; + + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); + expect(routeConfig.options).toEqual({ + authRequired: true, + tags: ['access:securitySolution'], + }); + expect(mockResponse.ok).toBeCalled(); + const result = mockResponse.ok.mock.calls[0][0]?.body as HostInfo; + expect(result.host_status).toEqual(HostStatus.UNHEALTHY); + }); + + it('should throw error when endpoint agent is not active', async () => { + const response = legacyMetadataSearchResponseMock( + new EndpointDocGenerator().generateHostMetadata() + ); + + const mockRequest = httpServerMock.createKibanaRequest({ + params: { id: response.hits.hits[0]._id }, + }); + (mockScopedClient.asCurrentUser.search as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ body: response }) + ); + mockAgentService.getAgent = jest.fn().mockReturnValue({ + active: false, + } as unknown as Agent); + + [routeConfig, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith(HOST_METADATA_GET_ROUTE) + )!; + + await routeHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockScopedClient.asCurrentUser.search).toHaveBeenCalledTimes(1); + expect(mockResponse.badRequest).toBeCalled(); }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts index a0cb5aad552d2..0a8f2616c516f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.test.ts @@ -5,39 +5,35 @@ * 2.0. */ -import { httpServerMock, loggingSystemMock } from '../../../../../../../src/core/server/mocks'; import { kibanaRequestToMetadataListESQuery, getESQueryHostMetadataByID, buildUnitedIndexQuery, } from './query_builders'; -import { EndpointAppContextService } from '../../endpoint_app_context_services'; -import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; import { metadataCurrentIndexPattern } from '../../../../common/endpoint/constants'; -import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; import { get } from 'lodash'; import { expectedCompleteUnitedIndexQuery } from './query_builders.fixtures'; describe('query builder', () => { describe('MetadataListESQuery', () => { it('queries the correct index', async () => { - const mockRequest = httpServerMock.createKibanaRequest({ body: {} }); - const query = await kibanaRequestToMetadataListESQuery(mockRequest, { - logFactory: loggingSystemMock.create(), - service: new EndpointAppContextService(), - config: () => Promise.resolve(createMockConfig()), - experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental), + const query = await kibanaRequestToMetadataListESQuery({ + page: 0, + pageSize: 10, + kuery: '', + unenrolledAgentIds: [], + statusAgentIds: [], }); expect(query.index).toEqual(metadataCurrentIndexPattern); }); it('sorts using *event.created', async () => { - const mockRequest = httpServerMock.createKibanaRequest({ body: {} }); - const query = await kibanaRequestToMetadataListESQuery(mockRequest, { - logFactory: loggingSystemMock.create(), - service: new EndpointAppContextService(), - config: () => Promise.resolve(createMockConfig()), - experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental), + const query = await kibanaRequestToMetadataListESQuery({ + page: 0, + pageSize: 10, + kuery: '', + unenrolledAgentIds: [], + statusAgentIds: [], }); expect(query.body.sort).toContainEqual({ 'event.created': { @@ -55,21 +51,13 @@ describe('query builder', () => { it('excludes unenrolled elastic agents when they exist, by default', async () => { const unenrolledElasticAgentId = '1fdca33f-799f-49f4-939c-ea4383c77672'; - const mockRequest = httpServerMock.createKibanaRequest({ - body: {}, + const query = await kibanaRequestToMetadataListESQuery({ + page: 0, + pageSize: 10, + kuery: '', + unenrolledAgentIds: [unenrolledElasticAgentId], + statusAgentIds: [], }); - const query = await kibanaRequestToMetadataListESQuery( - mockRequest, - { - logFactory: loggingSystemMock.create(), - service: new EndpointAppContextService(), - config: () => Promise.resolve(createMockConfig()), - experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental), - }, - { - unenrolledAgentIds: [unenrolledElasticAgentId], - } - ); expect(query.body.query).toEqual({ bool: { @@ -100,16 +88,12 @@ describe('query builder', () => { describe('test query builder with kql filter', () => { it('test default query params for all endpoints metadata when body filter is provided', async () => { - const mockRequest = httpServerMock.createKibanaRequest({ - body: { - filters: { kql: 'not host.ip:10.140.73.246' }, - }, - }); - const query = await kibanaRequestToMetadataListESQuery(mockRequest, { - logFactory: loggingSystemMock.create(), - service: new EndpointAppContextService(), - config: () => Promise.resolve(createMockConfig()), - experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental), + const query = await kibanaRequestToMetadataListESQuery({ + page: 0, + pageSize: 10, + kuery: 'not host.ip:10.140.73.246', + unenrolledAgentIds: [], + statusAgentIds: [], }); expect(query.body.query.bool.must).toContainEqual({ @@ -135,25 +119,13 @@ describe('query builder', () => { 'and when body filter is provided', async () => { const unenrolledElasticAgentId = '1fdca33f-799f-49f4-939c-ea4383c77672'; - const mockRequest = httpServerMock.createKibanaRequest({ - body: { - filters: { kql: 'not host.ip:10.140.73.246' }, - }, + const query = await kibanaRequestToMetadataListESQuery({ + page: 0, + pageSize: 10, + kuery: 'not host.ip:10.140.73.246', + unenrolledAgentIds: [unenrolledElasticAgentId], + statusAgentIds: [], }); - const query = await kibanaRequestToMetadataListESQuery( - mockRequest, - { - logFactory: loggingSystemMock.create(), - service: new EndpointAppContextService(), - config: () => Promise.resolve(createMockConfig()), - experimentalFeatures: parseExperimentalConfigValue( - createMockConfig().enableExperimental - ), - }, - { - unenrolledAgentIds: [unenrolledElasticAgentId], - } - ); expect(query.body.query.bool.must).toEqual([ { @@ -222,7 +194,10 @@ describe('query builder', () => { describe('buildUnitedIndexQuery', () => { it('correctly builds empty query', async () => { - const query = await buildUnitedIndexQuery({ page: 1, pageSize: 10, filters: {} }, []); + const query = await buildUnitedIndexQuery( + { page: 1, pageSize: 10, hostStatuses: [], kuery: '' }, + [] + ); const expected = { bool: { must_not: { @@ -267,10 +242,8 @@ describe('query builder', () => { { page: 1, pageSize: 10, - filters: { - kql: 'united.endpoint.host.os.name : *', - host_status: ['healthy'], - }, + kuery: 'united.endpoint.host.os.name : *', + hostStatuses: ['healthy'], }, ['test-endpoint-policy-id'] ); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts index 73325a92a3324..2262028ec43bf 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/query_builders.ts @@ -6,14 +6,16 @@ */ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { TypeOf } from '@kbn/config-schema'; import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; import { metadataCurrentIndexPattern, METADATA_UNITED_INDEX, } from '../../../../common/endpoint/constants'; import { KibanaRequest } from '../../../../../../../src/core/server'; -import { EndpointAppContext, GetHostMetadataListQuery } from '../../types'; +import { EndpointAppContext } from '../../types'; import { buildStatusesKuery } from './support/agent_status'; +import { GetMetadataListRequestSchemaV2 } from '.'; /** * 00000000-0000-0000-0000-000000000000 is initial Elastic Agent id sent by Endpoint before policy is configured @@ -25,6 +27,9 @@ const IGNORED_ELASTIC_AGENT_IDS = [ ]; export interface QueryBuilderOptions { + page: number; + pageSize: number; + kuery?: string; unenrolledAgentIds?: string[]; statusAgentIds?: string[]; } @@ -50,26 +55,21 @@ export const MetadataSortMethod: estypes.SearchSortContainer[] = [ ]; export async function kibanaRequestToMetadataListESQuery( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - request: KibanaRequest, - endpointAppContext: EndpointAppContext, - queryBuilderOptions?: QueryBuilderOptions + queryBuilderOptions: QueryBuilderOptions // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise> { - const pagingProperties = await getPagingProperties(request, endpointAppContext); - return { body: { query: buildQueryBody( - request, + queryBuilderOptions?.kuery, IGNORED_ELASTIC_AGENT_IDS.concat(queryBuilderOptions?.unenrolledAgentIds ?? []), queryBuilderOptions?.statusAgentIds ), track_total_hits: true, sort: MetadataSortMethod, }, - from: pagingProperties.pageIndex * pagingProperties.pageSize, - size: pagingProperties.pageSize, + from: queryBuilderOptions.page * queryBuilderOptions.pageSize, + size: queryBuilderOptions.pageSize, index: metadataCurrentIndexPattern, }; } @@ -96,8 +96,7 @@ export async function getPagingProperties( } function buildQueryBody( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - request: KibanaRequest, + kuery: string = '', unerolledAgentIds: string[] | undefined, statusAgentIds: string[] | undefined // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -136,8 +135,8 @@ function buildQueryBody( }, }; - if (request?.body?.filters?.kql) { - const kqlQuery = toElasticsearchQuery(fromKueryExpression(request.body.filters.kql)); + if (kuery) { + const kqlQuery = toElasticsearchQuery(fromKueryExpression(kuery)); const q = []; if (filterUnenrolledAgents || filterStatusAgents) { q.push(idFilter); @@ -233,12 +232,17 @@ interface BuildUnitedIndexQueryResponse { size: number; index: string; } + export async function buildUnitedIndexQuery( - { page = 1, pageSize = 10, filters = {} }: GetHostMetadataListQuery, + { + page = 0, + pageSize = 10, + hostStatuses = [], + kuery = '', + }: TypeOf, endpointPolicyIds: string[] = [] ): Promise { - const statusesToFilter = filters?.host_status ?? []; - const statusesKuery = buildStatusesKuery(statusesToFilter); + const statusesKuery = buildStatusesKuery(hostStatuses); const filterIgnoredAgents = { must_not: { terms: { 'agent.id': IGNORED_ELASTIC_AGENT_IDS } }, @@ -272,8 +276,8 @@ export async function buildUnitedIndexQuery( let query: BuildUnitedIndexQueryResponse['body']['query'] = idFilter; - if (statusesKuery || filters?.kql) { - const kqlQuery = toElasticsearchQuery(fromKueryExpression(filters.kql ?? '')); + if (statusesKuery || kuery) { + const kqlQuery = toElasticsearchQuery(fromKueryExpression(kuery ?? '')); const q = []; if (filterIgnoredAgents || filterEndpointPolicyAgents) { @@ -295,7 +299,7 @@ export async function buildUnitedIndexQuery( track_total_hits: true, sort: MetadataSortMethod, }, - from: (page - 1) * pageSize, + from: page * pageSize, size: pageSize, index: METADATA_UNITED_INDEX, }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/agent_status.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/agent_status.ts index f9e04f4edebee..a7781cb77e8c0 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/agent_status.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/support/agent_status.ts @@ -36,7 +36,7 @@ export function buildStatusesKuery(statusesToFilter: string[]): string | undefin export async function findAgentIdsByStatus( agentService: AgentService, esClient: ElasticsearchClient, - statuses: string[], + statuses: string[] = [], pageSize: number = 1000 ): Promise { if (!statuses.length) { diff --git a/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.test.ts index d3cc7b32bbc1c..5af108304ff9d 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.test.ts @@ -117,7 +117,12 @@ describe('EndpointMetadataService', () => { it('should throw wrapped error if es error', async () => { const esMockResponse = elasticsearchServiceMock.createErrorTransportRequestPromise({}); esClient.search.mockResolvedValue(esMockResponse); - const metadataListResponse = metadataService.getHostMetadataList(esClient); + const metadataListResponse = metadataService.getHostMetadataList(esClient, { + page: 0, + pageSize: 10, + kuery: '', + hostStatuses: [], + }); await expect(metadataListResponse).rejects.toThrow(EndpointError); }); @@ -168,18 +173,16 @@ describe('EndpointMetadataService', () => { } ); - const metadataListResponse = await metadataService.getHostMetadataList(esClient); - const unitedIndexQuery = await buildUnitedIndexQuery( - { page: 1, pageSize: 10, filters: {} }, - packagePolicyIds + const queryOptions = { page: 1, pageSize: 10, kuery: '', hostStatuses: [] }; + const metadataListResponse = await metadataService.getHostMetadataList( + esClient, + queryOptions ); + const unitedIndexQuery = await buildUnitedIndexQuery(queryOptions, packagePolicyIds); expect(esClient.search).toBeCalledWith(unitedIndexQuery); expect(agentPolicyServiceMock.getByIds).toBeCalledWith(expect.anything(), agentPolicyIds); expect(metadataListResponse).toEqual({ - pageSize: 10, - page: 1, - total: 1, data: [ { metadata: endpointMetadataDoc, @@ -202,6 +205,7 @@ describe('EndpointMetadataService', () => { }, }, ], + total: 1, }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts b/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts index be8a6625c111e..832b8b507e5d4 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/metadata/endpoint_metadata_service.ts @@ -12,12 +12,14 @@ import { SavedObjectsServiceStart, } from 'kibana/server'; +import { TypeOf } from '@kbn/config-schema'; import { TransportResult } from '@elastic/elasticsearch'; import { SearchTotalHits, SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import { HostInfo, HostMetadata, MaybeImmutable, + MetadataListResponse, PolicyData, UnitedAgentMetadata, } from '../../../../common/endpoint/types'; @@ -52,10 +54,10 @@ import { } from '../../utils'; import { EndpointError } from '../../errors'; import { createInternalReadonlySoClient } from '../../utils/create_internal_readonly_so_client'; -import { GetHostMetadataListQuery } from '../../types'; import { METADATA_UNITED_INDEX } from '../../../../common/endpoint/constants'; import { getAllEndpointPackagePolicies } from '../../routes/metadata/support/endpoint_package_policies'; import { getAgentStatus } from '../../../../../fleet/common/services/agent_status'; +import { GetMetadataListRequestSchemaV2 } from '../../routes/metadata'; type AgentPolicyWithPackagePolicies = Omit & { package_policies: PackagePolicy[]; @@ -401,8 +403,8 @@ export class EndpointMetadataService { */ async getHostMetadataList( esClient: ElasticsearchClient, - queryOptions: GetHostMetadataListQuery = {} - ): Promise<{ data: HostInfo[]; total: number; page: number; pageSize: number }> { + queryOptions: TypeOf + ): Promise> { const endpointPolicies = await getAllEndpointPackagePolicies( this.packagePolicyService, this.DANGEROUS_INTERNAL_SO_CLIENT @@ -474,8 +476,6 @@ export class EndpointMetadataService { return { data: hosts, - pageSize: unitedIndexQuery.size, - page: unitedIndexQuery.from + 1, total: (docsCount as unknown as SearchTotalHits).value, }; } diff --git a/x-pack/plugins/security_solution/server/endpoint/types.ts b/x-pack/plugins/security_solution/server/endpoint/types.ts index 919e62785f698..bc52b759b9f0a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/types.ts +++ b/x-pack/plugins/security_solution/server/endpoint/types.ts @@ -7,12 +7,10 @@ import { LoggerFactory } from 'kibana/server'; -import { TypeOf } from '@kbn/config-schema'; import { ConfigType } from '../config'; import { EndpointAppContextService } from './endpoint_app_context_services'; import { HostMetadata } from '../../common/endpoint/types'; import { ExperimentalFeatures } from '../../common/experimental_features'; -import { endpointFilters } from './routes/metadata'; /** * The context for Endpoint apps. @@ -37,11 +35,3 @@ export interface HostQueryResult { resultLength: number; result: HostMetadata | undefined; } - -// FIXME: when new Host Metadata list API is created (and existing one deprecated - 8.0?), move this type out of here and created it from Schema -export interface GetHostMetadataListQuery { - /* page number 1 based - not an index */ - page?: number; - pageSize?: number; - filters?: Partial>; -} diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index 35e316f309d7f..471d00728bac3 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -5,6 +5,7 @@ * 2.0. */ +import uuid from 'uuid'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; import { @@ -31,317 +32,56 @@ import { indexFleetEndpointPolicy } from '../../../plugins/security_solution/com export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); - describe('test metadata api', () => { - describe('with .metrics-endpoint.metadata_united_default index', () => { - const numberOfHostsInFixture = 2; - - before(async () => { - await stopTransform(getService, `${METADATA_UNITED_TRANSFORM}*`); - await deleteAllDocsFromFleetAgents(getService); - await deleteAllDocsFromMetadataDatastream(getService); - await deleteAllDocsFromMetadataCurrentIndex(getService); - await deleteAllDocsFromIndex(getService, METADATA_UNITED_INDEX); - - // generate an endpoint policy and attach id to agents since - // metadata list api filters down to endpoint policies only - const policy = await indexFleetEndpointPolicy( - getService('kibanaServer'), - 'Default', - '1.1.1' - ); - const policyId = policy.integrationPolicies[0].policy_id; - const currentTime = new Date().getTime(); - - await Promise.all([ - bulkIndex(getService, AGENTS_INDEX, generateAgentDocs(currentTime, policyId)), - bulkIndex(getService, METADATA_DATASTREAM, generateMetadataDocs(currentTime)), - ]); - - // wait for latest metadata transform to run - await new Promise((r) => setTimeout(r, 30000)); - await startTransform(getService, METADATA_UNITED_TRANSFORM); - - // wait for united metadata transform to run - await new Promise((r) => setTimeout(r, 15000)); - }); - - after(async () => { - await deleteAllDocsFromFleetAgents(getService); - await deleteAllDocsFromMetadataDatastream(getService); - await deleteAllDocsFromMetadataCurrentIndex(getService); - await stopTransform(getService, `${METADATA_UNITED_TRANSFORM}*`); - await deleteAllDocsFromIndex(getService, METADATA_UNITED_INDEX); - }); - - it('should return one entry for each host with default paging', async () => { - const res = await supertest - .post(HOST_METADATA_LIST_ROUTE) - .set('kbn-xsrf', 'xxx') - .send() - .expect(200); - const { body } = res; - expect(body.total).to.eql(numberOfHostsInFixture); - expect(body.hosts.length).to.eql(numberOfHostsInFixture); - expect(body.request_page_size).to.eql(10); - expect(body.request_page_index).to.eql(0); - }); + describe('test metadata apis', () => { + describe('list endpoints POST route', () => { + describe('with .metrics-endpoint.metadata_united_default index', () => { + const numberOfHostsInFixture = 2; - it('metadata api should return page based on paging properties passed.', async () => { - const { body } = await supertest - .post(HOST_METADATA_LIST_ROUTE) - .set('kbn-xsrf', 'xxx') - .send({ - paging_properties: [ - { - page_size: 1, - }, - { - page_index: 1, - }, - ], - }) - .expect(200); - expect(body.total).to.eql(numberOfHostsInFixture); - expect(body.hosts.length).to.eql(1); - expect(body.request_page_size).to.eql(1); - expect(body.request_page_index).to.eql(1); - }); - - it('metadata api should return accurate total metadata if page index produces no result', async () => { - const { body } = await supertest - .post(HOST_METADATA_LIST_ROUTE) - .set('kbn-xsrf', 'xxx') - .send({ - paging_properties: [ - { - page_size: 10, - }, - { - page_index: 3, - }, - ], - }) - .expect(200); - expect(body.total).to.eql(numberOfHostsInFixture); - expect(body.hosts.length).to.eql(0); - expect(body.request_page_size).to.eql(10); - expect(body.request_page_index).to.eql(30); - }); - - it('metadata api should return 400 when pagingProperties is below boundaries.', async () => { - const { body } = await supertest - .post(HOST_METADATA_LIST_ROUTE) - .set('kbn-xsrf', 'xxx') - .send({ - paging_properties: [ - { - page_size: 0, - }, - { - page_index: 1, - }, - ], - }) - .expect(400); - expect(body.message).to.contain('Value must be equal to or greater than [1]'); - }); - - it('metadata api should return page based on filters passed.', async () => { - const { body } = await supertest - .post(HOST_METADATA_LIST_ROUTE) - .set('kbn-xsrf', 'xxx') - .send({ - filters: { - kql: 'not (united.endpoint.host.ip:10.101.149.26)', - }, - }) - .expect(200); - expect(body.total).to.eql(1); - expect(body.hosts.length).to.eql(1); - expect(body.request_page_size).to.eql(10); - expect(body.request_page_index).to.eql(0); - }); - - it('metadata api should return page based on filters and paging passed.', async () => { - const notIncludedIp = '10.101.149.26'; - const { body } = await supertest - .post(HOST_METADATA_LIST_ROUTE) - .set('kbn-xsrf', 'xxx') - .send({ - paging_properties: [ - { - page_size: 10, - }, - { - page_index: 0, - }, - ], - filters: { - kql: `not (united.endpoint.host.ip:${notIncludedIp})`, - }, - }) - .expect(200); - expect(body.total).to.eql(1); - const resultIps: string[] = [].concat( - ...body.hosts.map((hostInfo: Record) => hostInfo.metadata.host.ip) - ); - expect(resultIps.sort()).to.eql(['10.192.213.130', '10.70.28.129'].sort()); - expect(resultIps).not.include.eql(notIncludedIp); - expect(body.hosts.length).to.eql(1); - expect(body.request_page_size).to.eql(10); - expect(body.request_page_index).to.eql(0); - }); - - it('metadata api should return page based on host.os.Ext.variant filter.', async () => { - const variantValue = 'Windows Pro'; - const { body } = await supertest - .post(HOST_METADATA_LIST_ROUTE) - .set('kbn-xsrf', 'xxx') - .send({ - filters: { - kql: `united.endpoint.host.os.Ext.variant:${variantValue}`, - }, - }) - .expect(200); - expect(body.total).to.eql(2); - const resultOsVariantValue: Set = new Set( - body.hosts.map((hostInfo: Record) => hostInfo.metadata.host.os.Ext.variant) - ); - expect(Array.from(resultOsVariantValue)).to.eql([variantValue]); - expect(body.hosts.length).to.eql(2); - expect(body.request_page_size).to.eql(10); - expect(body.request_page_index).to.eql(0); - }); - - it('metadata api should return the latest event for all the events for an endpoint', async () => { - const targetEndpointIp = '10.101.149.26'; - const { body } = await supertest - .post(HOST_METADATA_LIST_ROUTE) - .set('kbn-xsrf', 'xxx') - .send({ - filters: { - kql: `united.endpoint.host.ip:${targetEndpointIp}`, - }, - }) - .expect(200); - expect(body.total).to.eql(1); - const resultIp: string = body.hosts[0].metadata.host.ip.filter( - (ip: string) => ip === targetEndpointIp - ); - expect(resultIp).to.eql([targetEndpointIp]); - expect(body.hosts.length).to.eql(1); - expect(body.request_page_size).to.eql(10); - expect(body.request_page_index).to.eql(0); - }); - - it('metadata api should return the latest event for all the events where policy status is not success', async () => { - const { body } = await supertest - .post(HOST_METADATA_LIST_ROUTE) - .set('kbn-xsrf', 'xxx') - .send({ - filters: { - kql: `not (united.endpoint.Endpoint.policy.applied.status:success)`, - }, - }) - .expect(200); - const statuses: Set = new Set( - body.hosts.map( - (hostInfo: Record) => hostInfo.metadata.Endpoint.policy.applied.status - ) - ); - expect(statuses.size).to.eql(1); - expect(Array.from(statuses)).to.eql(['failure']); - }); + before(async () => { + await stopTransform(getService, `${METADATA_UNITED_TRANSFORM}*`); + await deleteAllDocsFromFleetAgents(getService); + await deleteAllDocsFromMetadataDatastream(getService); + await deleteAllDocsFromMetadataCurrentIndex(getService); + await deleteAllDocsFromIndex(getService, METADATA_UNITED_INDEX); - it('metadata api should return the endpoint based on the elastic agent id, and status should be healthy', async () => { - const targetEndpointId = 'fc0ff548-feba-41b6-8367-65e8790d0eaf'; - const targetElasticAgentId = '023fa40c-411d-4188-a941-4147bfadd095'; - const { body } = await supertest - .post(HOST_METADATA_LIST_ROUTE) - .set('kbn-xsrf', 'xxx') - .send({ - filters: { - kql: `united.endpoint.elastic.agent.id:${targetElasticAgentId}`, - }, - }) - .expect(200); - expect(body.total).to.eql(1); - const resultHostId: string = body.hosts[0].metadata.host.id; - const resultElasticAgentId: string = body.hosts[0].metadata.elastic.agent.id; - expect(resultHostId).to.eql(targetEndpointId); - expect(resultElasticAgentId).to.eql(targetElasticAgentId); - expect(body.hosts[0].host_status).to.eql('healthy'); - expect(body.hosts.length).to.eql(1); - expect(body.request_page_size).to.eql(10); - expect(body.request_page_index).to.eql(0); - }); + // generate an endpoint policy and attach id to agents since + // metadata list api filters down to endpoint policies only + const policy = await indexFleetEndpointPolicy( + getService('kibanaServer'), + `Default ${uuid.v4()}`, + '1.1.1' + ); + const policyId = policy.integrationPolicies[0].policy_id; + const currentTime = new Date().getTime(); - it('metadata api should return all hosts when filter is empty string', async () => { - const { body } = await supertest - .post(HOST_METADATA_LIST_ROUTE) - .set('kbn-xsrf', 'xxx') - .send({ - filters: { - kql: '', - }, - }) - .expect(200); - expect(body.total).to.eql(numberOfHostsInFixture); - expect(body.hosts.length).to.eql(numberOfHostsInFixture); - expect(body.request_page_size).to.eql(10); - expect(body.request_page_index).to.eql(0); - }); - }); + await Promise.all([ + bulkIndex(getService, AGENTS_INDEX, generateAgentDocs(currentTime, policyId)), + bulkIndex(getService, METADATA_DATASTREAM, generateMetadataDocs(currentTime)), + ]); - describe('with metrics-endpoint.metadata_current_default index', () => { - /** - * The number of host documents in the es archive. - */ - const numberOfHostsInFixture = 3; + // wait for latest metadata transform to run + await new Promise((r) => setTimeout(r, 30000)); + await startTransform(getService, METADATA_UNITED_TRANSFORM); - describe(`POST ${HOST_METADATA_LIST_ROUTE} when index is empty`, () => { - it('metadata api should return empty result when index is empty', async () => { - await stopTransform(getService, `${METADATA_UNITED_TRANSFORM}*`); - await deleteIndex(getService, METADATA_UNITED_INDEX); - await deleteMetadataStream(getService); - await deleteAllDocsFromMetadataDatastream(getService); - await deleteAllDocsFromMetadataCurrentIndex(getService); - const { body } = await supertest - .post(HOST_METADATA_LIST_ROUTE) - .set('kbn-xsrf', 'xxx') - .send() - .expect(200); - expect(body.total).to.eql(0); - expect(body.hosts.length).to.eql(0); - expect(body.request_page_size).to.eql(10); - expect(body.request_page_index).to.eql(0); + // wait for united metadata transform to run + await new Promise((r) => setTimeout(r, 15000)); }); - }); - describe(`POST ${HOST_METADATA_LIST_ROUTE} when index is not empty`, () => { - const timestamp = new Date().getTime(); - before(async () => { - // stop the united transform and delete the index - // otherwise it won't hit metrics-endpoint.metadata_current_default index - await stopTransform(getService, `${METADATA_UNITED_TRANSFORM}*`); - await deleteIndex(getService, METADATA_UNITED_INDEX); - await bulkIndex(getService, METADATA_DATASTREAM, generateMetadataDocs(timestamp)); - // wait for transform - await new Promise((r) => setTimeout(r, 60000)); - }); - // the endpoint uses data streams and es archiver does not support deleting them at the moment so we need - // to do it manually after(async () => { - await deleteMetadataStream(getService); + await deleteAllDocsFromFleetAgents(getService); await deleteAllDocsFromMetadataDatastream(getService); await deleteAllDocsFromMetadataCurrentIndex(getService); + await stopTransform(getService, `${METADATA_UNITED_TRANSFORM}*`); + await deleteAllDocsFromIndex(getService, METADATA_UNITED_INDEX); }); - it('metadata api should return one entry for each host with default paging', async () => { - const { body } = await supertest + + it('should return one entry for each host with default paging', async () => { + const res = await supertest .post(HOST_METADATA_LIST_ROUTE) .set('kbn-xsrf', 'xxx') .send() .expect(200); + const { body } = res; expect(body.total).to.eql(numberOfHostsInFixture); expect(body.hosts.length).to.eql(numberOfHostsInFixture); expect(body.request_page_size).to.eql(10); @@ -369,9 +109,6 @@ export default function ({ getService }: FtrProviderContext) { expect(body.request_page_index).to.eql(1); }); - /* test that when paging properties produces no result, the total should reflect the actual number of metadata - in the index. - */ it('metadata api should return accurate total metadata if page index produces no result', async () => { const { body } = await supertest .post(HOST_METADATA_LIST_ROUTE) @@ -417,18 +154,18 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ filters: { - kql: 'not (HostDetails.host.ip:10.46.229.234 or host.ip:10.46.229.234)', + kql: 'not (united.endpoint.host.ip:10.101.149.26)', }, }) .expect(200); - expect(body.total).to.eql(2); - expect(body.hosts.length).to.eql(2); + expect(body.total).to.eql(1); + expect(body.hosts.length).to.eql(1); expect(body.request_page_size).to.eql(10); expect(body.request_page_index).to.eql(0); }); it('metadata api should return page based on filters and paging passed.', async () => { - const notIncludedIp = '10.46.229.234'; + const notIncludedIp = '10.101.149.26'; const { body } = await supertest .post(HOST_METADATA_LIST_ROUTE) .set('kbn-xsrf', 'xxx') @@ -442,24 +179,17 @@ export default function ({ getService }: FtrProviderContext) { }, ], filters: { - kql: `not (HostDetails.host.ip:${notIncludedIp} or host.ip:${notIncludedIp})`, + kql: `not (united.endpoint.host.ip:${notIncludedIp})`, }, }) .expect(200); - expect(body.total).to.eql(2); + expect(body.total).to.eql(1); const resultIps: string[] = [].concat( ...body.hosts.map((hostInfo: Record) => hostInfo.metadata.host.ip) ); - expect(resultIps.sort()).to.eql( - [ - '10.192.213.130', - '10.70.28.129', - '10.101.149.26', - '2606:a000:ffc0:39:11ef:37b9:3371:578c', - ].sort() - ); + expect(resultIps.sort()).to.eql(['10.192.213.130', '10.70.28.129'].sort()); expect(resultIps).not.include.eql(notIncludedIp); - expect(body.hosts.length).to.eql(2); + expect(body.hosts.length).to.eql(1); expect(body.request_page_size).to.eql(10); expect(body.request_page_index).to.eql(0); }); @@ -471,7 +201,7 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ filters: { - kql: `HostDetails.host.os.Ext.variant:${variantValue} or host.os.Ext.variant:${variantValue}`, + kql: `united.endpoint.host.os.Ext.variant:${variantValue}`, }, }) .expect(200); @@ -486,13 +216,13 @@ export default function ({ getService }: FtrProviderContext) { }); it('metadata api should return the latest event for all the events for an endpoint', async () => { - const targetEndpointIp = '10.46.229.234'; + const targetEndpointIp = '10.101.149.26'; const { body } = await supertest .post(HOST_METADATA_LIST_ROUTE) .set('kbn-xsrf', 'xxx') .send({ filters: { - kql: `HostDetails.host.ip:${targetEndpointIp} or host.ip:${targetEndpointIp}`, + kql: `united.endpoint.host.ip:${targetEndpointIp}`, }, }) .expect(200); @@ -501,7 +231,6 @@ export default function ({ getService }: FtrProviderContext) { (ip: string) => ip === targetEndpointIp ); expect(resultIp).to.eql([targetEndpointIp]); - expect(body.hosts[0].metadata.event.created).to.eql(timestamp); expect(body.hosts.length).to.eql(1); expect(body.request_page_size).to.eql(10); expect(body.request_page_index).to.eql(0); @@ -513,7 +242,7 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ filters: { - kql: `not (HostDetails.Endpoint.policy.applied.status:success or Endpoint.policy.applied.status:success)`, + kql: `not (united.endpoint.Endpoint.policy.applied.status:success)`, }, }) .expect(200); @@ -526,7 +255,7 @@ export default function ({ getService }: FtrProviderContext) { expect(Array.from(statuses)).to.eql(['failure']); }); - it('metadata api should return the endpoint based on the elastic agent id, and status should be unhealthy', async () => { + it('metadata api should return the endpoint based on the elastic agent id, and status should be healthy', async () => { const targetEndpointId = 'fc0ff548-feba-41b6-8367-65e8790d0eaf'; const targetElasticAgentId = '023fa40c-411d-4188-a941-4147bfadd095'; const { body } = await supertest @@ -534,7 +263,7 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ filters: { - kql: `HostDetails.elastic.agent.id:${targetElasticAgentId} or elastic.agent.id:${targetElasticAgentId}`, + kql: `united.endpoint.elastic.agent.id:${targetElasticAgentId}`, }, }) .expect(200); @@ -543,8 +272,7 @@ export default function ({ getService }: FtrProviderContext) { const resultElasticAgentId: string = body.hosts[0].metadata.elastic.agent.id; expect(resultHostId).to.eql(targetEndpointId); expect(resultElasticAgentId).to.eql(targetElasticAgentId); - expect(body.hosts[0].metadata.event.created).to.eql(timestamp); - expect(body.hosts[0].host_status).to.eql('unhealthy'); + expect(body.hosts[0].host_status).to.eql('healthy'); expect(body.hosts.length).to.eql(1); expect(body.request_page_size).to.eql(10); expect(body.request_page_index).to.eql(0); @@ -566,6 +294,755 @@ export default function ({ getService }: FtrProviderContext) { expect(body.request_page_index).to.eql(0); }); }); + + describe('with metrics-endpoint.metadata_current_default index', () => { + /** + * The number of host documents in the es archive. + */ + const numberOfHostsInFixture = 3; + + describe(`POST ${HOST_METADATA_LIST_ROUTE} when index is empty`, () => { + it('metadata api should return empty result when index is empty', async () => { + await stopTransform(getService, `${METADATA_UNITED_TRANSFORM}*`); + await deleteIndex(getService, METADATA_UNITED_INDEX); + await deleteMetadataStream(getService); + await deleteAllDocsFromMetadataDatastream(getService); + await deleteAllDocsFromMetadataCurrentIndex(getService); + const { body } = await supertest + .post(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .send() + .expect(200); + expect(body.total).to.eql(0); + expect(body.hosts.length).to.eql(0); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(0); + }); + }); + + describe(`POST ${HOST_METADATA_LIST_ROUTE} when index is not empty`, () => { + const timestamp = new Date().getTime(); + before(async () => { + // stop the united transform and delete the index + // otherwise it won't hit metrics-endpoint.metadata_current_default index + await stopTransform(getService, `${METADATA_UNITED_TRANSFORM}*`); + await deleteIndex(getService, METADATA_UNITED_INDEX); + await bulkIndex(getService, METADATA_DATASTREAM, generateMetadataDocs(timestamp)); + // wait for transform + await new Promise((r) => setTimeout(r, 60000)); + }); + // the endpoint uses data streams and es archiver does not support deleting them at the moment so we need + // to do it manually + after(async () => { + await deleteMetadataStream(getService); + await deleteAllDocsFromMetadataDatastream(getService); + await deleteAllDocsFromMetadataCurrentIndex(getService); + }); + it('metadata api should return one entry for each host with default paging', async () => { + const { body } = await supertest + .post(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .send() + .expect(200); + expect(body.total).to.eql(numberOfHostsInFixture); + expect(body.hosts.length).to.eql(numberOfHostsInFixture); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(0); + }); + + it('metadata api should return page based on paging properties passed.', async () => { + const { body } = await supertest + .post(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .send({ + paging_properties: [ + { + page_size: 1, + }, + { + page_index: 1, + }, + ], + }) + .expect(200); + expect(body.total).to.eql(numberOfHostsInFixture); + expect(body.hosts.length).to.eql(1); + expect(body.request_page_size).to.eql(1); + expect(body.request_page_index).to.eql(1); + }); + + /* test that when paging properties produces no result, the total should reflect the actual number of metadata + in the index. + */ + it('metadata api should return accurate total metadata if page index produces no result', async () => { + const { body } = await supertest + .post(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .send({ + paging_properties: [ + { + page_size: 10, + }, + { + page_index: 3, + }, + ], + }) + .expect(200); + expect(body.total).to.eql(numberOfHostsInFixture); + expect(body.hosts.length).to.eql(0); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(30); + }); + + it('metadata api should return 400 when pagingProperties is below boundaries.', async () => { + const { body } = await supertest + .post(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .send({ + paging_properties: [ + { + page_size: 0, + }, + { + page_index: 1, + }, + ], + }) + .expect(400); + expect(body.message).to.contain('Value must be equal to or greater than [1]'); + }); + + it('metadata api should return page based on filters passed.', async () => { + const { body } = await supertest + .post(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .send({ + filters: { + kql: 'not (HostDetails.host.ip:10.46.229.234 or host.ip:10.46.229.234)', + }, + }) + .expect(200); + expect(body.total).to.eql(2); + expect(body.hosts.length).to.eql(2); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(0); + }); + + it('metadata api should return page based on filters and paging passed.', async () => { + const notIncludedIp = '10.46.229.234'; + const { body } = await supertest + .post(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .send({ + paging_properties: [ + { + page_size: 10, + }, + { + page_index: 0, + }, + ], + filters: { + kql: `not (HostDetails.host.ip:${notIncludedIp} or host.ip:${notIncludedIp})`, + }, + }) + .expect(200); + expect(body.total).to.eql(2); + const resultIps: string[] = [].concat( + ...body.hosts.map((hostInfo: Record) => hostInfo.metadata.host.ip) + ); + expect(resultIps.sort()).to.eql( + [ + '10.192.213.130', + '10.70.28.129', + '10.101.149.26', + '2606:a000:ffc0:39:11ef:37b9:3371:578c', + ].sort() + ); + expect(resultIps).not.include.eql(notIncludedIp); + expect(body.hosts.length).to.eql(2); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(0); + }); + + it('metadata api should return page based on host.os.Ext.variant filter.', async () => { + const variantValue = 'Windows Pro'; + const { body } = await supertest + .post(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .send({ + filters: { + kql: `HostDetails.host.os.Ext.variant:${variantValue} or host.os.Ext.variant:${variantValue}`, + }, + }) + .expect(200); + expect(body.total).to.eql(2); + const resultOsVariantValue: Set = new Set( + body.hosts.map( + (hostInfo: Record) => hostInfo.metadata.host.os.Ext.variant + ) + ); + expect(Array.from(resultOsVariantValue)).to.eql([variantValue]); + expect(body.hosts.length).to.eql(2); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(0); + }); + + it('metadata api should return the latest event for all the events for an endpoint', async () => { + const targetEndpointIp = '10.46.229.234'; + const { body } = await supertest + .post(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .send({ + filters: { + kql: `HostDetails.host.ip:${targetEndpointIp} or host.ip:${targetEndpointIp}`, + }, + }) + .expect(200); + expect(body.total).to.eql(1); + const resultIp: string = body.hosts[0].metadata.host.ip.filter( + (ip: string) => ip === targetEndpointIp + ); + expect(resultIp).to.eql([targetEndpointIp]); + expect(body.hosts[0].metadata.event.created).to.eql(timestamp); + expect(body.hosts.length).to.eql(1); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(0); + }); + + it('metadata api should return the latest event for all the events where policy status is not success', async () => { + const { body } = await supertest + .post(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .send({ + filters: { + kql: `not (HostDetails.Endpoint.policy.applied.status:success or Endpoint.policy.applied.status:success)`, + }, + }) + .expect(200); + const statuses: Set = new Set( + body.hosts.map( + (hostInfo: Record) => hostInfo.metadata.Endpoint.policy.applied.status + ) + ); + expect(statuses.size).to.eql(1); + expect(Array.from(statuses)).to.eql(['failure']); + }); + + it('metadata api should return the endpoint based on the elastic agent id, and status should be unhealthy', async () => { + const targetEndpointId = 'fc0ff548-feba-41b6-8367-65e8790d0eaf'; + const targetElasticAgentId = '023fa40c-411d-4188-a941-4147bfadd095'; + const { body } = await supertest + .post(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .send({ + filters: { + kql: `HostDetails.elastic.agent.id:${targetElasticAgentId} or elastic.agent.id:${targetElasticAgentId}`, + }, + }) + .expect(200); + expect(body.total).to.eql(1); + const resultHostId: string = body.hosts[0].metadata.host.id; + const resultElasticAgentId: string = body.hosts[0].metadata.elastic.agent.id; + expect(resultHostId).to.eql(targetEndpointId); + expect(resultElasticAgentId).to.eql(targetElasticAgentId); + expect(body.hosts[0].metadata.event.created).to.eql(timestamp); + expect(body.hosts[0].host_status).to.eql('unhealthy'); + expect(body.hosts.length).to.eql(1); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(0); + }); + + it('metadata api should return all hosts when filter is empty string', async () => { + const { body } = await supertest + .post(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .send({ + filters: { + kql: '', + }, + }) + .expect(200); + expect(body.total).to.eql(numberOfHostsInFixture); + expect(body.hosts.length).to.eql(numberOfHostsInFixture); + expect(body.request_page_size).to.eql(10); + expect(body.request_page_index).to.eql(0); + }); + }); + }); + }); + + describe('list endpoints GET route', () => { + describe('with .metrics-endpoint.metadata_united_default index', () => { + const numberOfHostsInFixture = 2; + + before(async () => { + await stopTransform(getService, `${METADATA_UNITED_TRANSFORM}*`); + await deleteAllDocsFromFleetAgents(getService); + await deleteAllDocsFromMetadataDatastream(getService); + await deleteAllDocsFromMetadataCurrentIndex(getService); + await deleteAllDocsFromIndex(getService, METADATA_UNITED_INDEX); + + // generate an endpoint policy and attach id to agents since + // metadata list api filters down to endpoint policies only + const policy = await indexFleetEndpointPolicy( + getService('kibanaServer'), + `Default ${uuid.v4()}`, + '1.1.1' + ); + const policyId = policy.integrationPolicies[0].policy_id; + const currentTime = new Date().getTime(); + + await Promise.all([ + bulkIndex(getService, AGENTS_INDEX, generateAgentDocs(currentTime, policyId)), + bulkIndex(getService, METADATA_DATASTREAM, generateMetadataDocs(currentTime)), + ]); + + // wait for latest metadata transform to run + await new Promise((r) => setTimeout(r, 30000)); + await startTransform(getService, METADATA_UNITED_TRANSFORM); + + // wait for united metadata transform to run + await new Promise((r) => setTimeout(r, 15000)); + }); + + after(async () => { + await deleteAllDocsFromFleetAgents(getService); + await deleteAllDocsFromMetadataDatastream(getService); + await deleteAllDocsFromMetadataCurrentIndex(getService); + await stopTransform(getService, `${METADATA_UNITED_TRANSFORM}*`); + await deleteAllDocsFromIndex(getService, METADATA_UNITED_INDEX); + }); + + it('should return one entry for each host with default paging', async () => { + const res = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + page: 0, + pageSize: 10, + }) + .expect(200); + const { body } = res; + expect(body.data.length).to.eql(numberOfHostsInFixture); + expect(body.total).to.eql(numberOfHostsInFixture); + expect(body.page).to.eql(0); + expect(body.pageSize).to.eql(10); + }); + + it('metadata api should return page based on paging properties passed', async () => { + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + page: 1, + pageSize: 1, + }) + .expect(200); + expect(body.data.length).to.eql(1); + expect(body.total).to.eql(numberOfHostsInFixture); + expect(body.page).to.eql(1); + expect(body.pageSize).to.eql(1); + }); + + it('metadata api should return accurate total metadata if page index produces no result', async () => { + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + page: 3, + pageSize: 10, + }) + .expect(200); + expect(body.data.length).to.eql(0); + expect(body.total).to.eql(numberOfHostsInFixture); + expect(body.page).to.eql(3); + expect(body.pageSize).to.eql(10); + }); + + it('metadata api should return 400 when pagingProperties is below boundaries.', async () => { + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + page: 1, + pageSize: 0, + }) + .expect(400); + expect(body.message).to.contain('Value must be equal to or greater than [1]'); + }); + + it('metadata api should return page based on filters passed.', async () => { + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + kuery: 'not (united.endpoint.host.ip:10.101.149.26)', + }) + .expect(200); + expect(body.data.length).to.eql(1); + expect(body.total).to.eql(1); + expect(body.page).to.eql(0); + expect(body.pageSize).to.eql(10); + }); + + it('metadata api should return page based on filters and paging passed.', async () => { + const notIncludedIp = '10.101.149.26'; + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + page: 0, + pageSize: 10, + kuery: `not (united.endpoint.host.ip:${notIncludedIp})`, + }) + .expect(200); + expect(body.total).to.eql(1); + const resultIps: string[] = [].concat( + ...body.data.map((hostInfo: Record) => hostInfo.metadata.host.ip) + ); + expect(resultIps.sort()).to.eql(['10.192.213.130', '10.70.28.129'].sort()); + expect(resultIps).not.include.eql(notIncludedIp); + expect(body.data.length).to.eql(1); + expect(body.page).to.eql(0); + expect(body.pageSize).to.eql(10); + }); + + it('metadata api should return page based on host.os.Ext.variant filter.', async () => { + const variantValue = 'Windows Pro'; + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + kuery: `united.endpoint.host.os.Ext.variant:${variantValue}`, + }) + .expect(200); + expect(body.total).to.eql(2); + const resultOsVariantValue: Set = new Set( + body.data.map((hostInfo: Record) => hostInfo.metadata.host.os.Ext.variant) + ); + expect(Array.from(resultOsVariantValue)).to.eql([variantValue]); + expect(body.data.length).to.eql(2); + expect(body.page).to.eql(0); + expect(body.pageSize).to.eql(10); + }); + + it('metadata api should return the latest event for all the events for an endpoint', async () => { + const targetEndpointIp = '10.101.149.26'; + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + kuery: `united.endpoint.host.ip:${targetEndpointIp}`, + }) + .expect(200); + expect(body.total).to.eql(1); + const resultIp: string = body.data[0].metadata.host.ip.filter( + (ip: string) => ip === targetEndpointIp + ); + expect(resultIp).to.eql([targetEndpointIp]); + expect(body.data.length).to.eql(1); + expect(body.page).to.eql(0); + expect(body.pageSize).to.eql(10); + }); + + it('metadata api should return the latest event for all the events where policy status is not success', async () => { + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + kuery: 'not (united.endpoint.Endpoint.policy.applied.status:success)', + }) + .expect(200); + const statuses: Set = new Set( + body.data.map( + (hostInfo: Record) => hostInfo.metadata.Endpoint.policy.applied.status + ) + ); + expect(statuses.size).to.eql(1); + expect(Array.from(statuses)).to.eql(['failure']); + }); + + it('metadata api should return the endpoint based on the elastic agent id, and status should be healthy', async () => { + const targetEndpointId = 'fc0ff548-feba-41b6-8367-65e8790d0eaf'; + const targetElasticAgentId = '023fa40c-411d-4188-a941-4147bfadd095'; + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + kuery: `united.endpoint.elastic.agent.id:${targetElasticAgentId}`, + }) + .expect(200); + expect(body.total).to.eql(1); + const resultHostId: string = body.data[0].metadata.host.id; + const resultElasticAgentId: string = body.data[0].metadata.elastic.agent.id; + expect(resultHostId).to.eql(targetEndpointId); + expect(resultElasticAgentId).to.eql(targetElasticAgentId); + expect(body.data.length).to.eql(1); + expect(body.data[0].host_status).to.eql('healthy'); + expect(body.page).to.eql(0); + expect(body.pageSize).to.eql(10); + }); + + it('metadata api should return all hosts when filter is empty string', async () => { + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .expect(200); + expect(body.data.length).to.eql(numberOfHostsInFixture); + expect(body.total).to.eql(numberOfHostsInFixture); + expect(body.page).to.eql(0); + expect(body.pageSize).to.eql(10); + }); + }); + + describe('with metrics-endpoint.metadata_current_default index', () => { + /** + * The number of host documents in the es archive. + */ + const numberOfHostsInFixture = 3; + + describe('when index is empty', () => { + it('metadata api should return empty result when index is empty', async () => { + await stopTransform(getService, `${METADATA_UNITED_TRANSFORM}*`); + await deleteIndex(getService, METADATA_UNITED_INDEX); + await deleteMetadataStream(getService); + await deleteAllDocsFromMetadataDatastream(getService); + await deleteAllDocsFromMetadataCurrentIndex(getService); + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + page: 0, + pageSize: 10, + }) + .expect(200); + expect(body.data.length).to.eql(0); + expect(body.total).to.eql(0); + expect(body.page).to.eql(0); + expect(body.pageSize).to.eql(10); + }); + }); + + describe('when index is not empty', () => { + const timestamp = new Date().getTime(); + before(async () => { + // stop the united transform and delete the index + // otherwise it won't hit metrics-endpoint.metadata_current_default index + await stopTransform(getService, `${METADATA_UNITED_TRANSFORM}*`); + await deleteIndex(getService, METADATA_UNITED_INDEX); + await bulkIndex(getService, METADATA_DATASTREAM, generateMetadataDocs(timestamp)); + // wait for transform + await new Promise((r) => setTimeout(r, 60000)); + }); + // the endpoint uses data streams and es archiver does not support deleting them at the moment so we need + // to do it manually + after(async () => { + await deleteMetadataStream(getService); + await deleteAllDocsFromMetadataDatastream(getService); + await deleteAllDocsFromMetadataCurrentIndex(getService); + }); + + it('metadata api should return one entry for each host with default paging', async () => { + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + page: 0, + pageSize: 10, + }) + .expect(200); + expect(body.data.length).to.eql(numberOfHostsInFixture); + expect(body.total).to.eql(numberOfHostsInFixture); + expect(body.page).to.eql(0); + expect(body.pageSize).to.eql(10); + }); + + it('metadata api should return page based on paging properties passed.', async () => { + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + page: 1, + pageSize: 1, + }) + .expect(200); + expect(body.data.length).to.eql(1); + expect(body.total).to.eql(numberOfHostsInFixture); + expect(body.page).to.eql(1); + expect(body.pageSize).to.eql(1); + }); + + /* test that when paging properties produces no result, the total should reflect the actual number of metadata + in the index. + */ + it('metadata api should return accurate total metadata if page index produces no result', async () => { + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + page: 3, + pageSize: 10, + }) + .expect(200); + expect(body.data.length).to.eql(0); + expect(body.total).to.eql(numberOfHostsInFixture); + expect(body.page).to.eql(3); + expect(body.pageSize).to.eql(10); + }); + + it('metadata api should return 400 when pagingProperties is below boundaries.', async () => { + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + page: 1, + pageSize: 0, + }) + .expect(400); + expect(body.message).to.contain('Value must be equal to or greater than [1]'); + }); + + it('metadata api should return page based on filters passed.', async () => { + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + kuery: 'not (HostDetails.host.ip:10.46.229.234 or host.ip:10.46.229.234)', + }) + .expect(200); + expect(body.data.length).to.eql(2); + expect(body.total).to.eql(2); + expect(body.page).to.eql(0); + expect(body.pageSize).to.eql(10); + }); + + it('metadata api should return page based on filters and paging passed.', async () => { + const notIncludedIp = '10.46.229.234'; + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + page: 0, + pageSize: 10, + kuery: `not (HostDetails.host.ip:${notIncludedIp} or host.ip:${notIncludedIp})`, + }) + .expect(200); + expect(body.data.length).to.eql(2); + expect(body.total).to.eql(2); + const resultIps: string[] = [].concat( + ...body.data.map((hostInfo: Record) => hostInfo.metadata.host.ip) + ); + expect(resultIps.sort()).to.eql( + [ + '10.192.213.130', + '10.70.28.129', + '10.101.149.26', + '2606:a000:ffc0:39:11ef:37b9:3371:578c', + ].sort() + ); + expect(resultIps).not.include.eql(notIncludedIp); + expect(body.page).to.eql(0); + expect(body.pageSize).to.eql(10); + }); + + it('metadata api should return page based on host.os.Ext.variant filter.', async () => { + const variantValue = 'Windows Pro'; + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + kuery: `HostDetails.host.os.Ext.variant:${variantValue} or host.os.Ext.variant:${variantValue}`, + }) + .expect(200); + expect(body.data.length).to.eql(2); + expect(body.total).to.eql(2); + const resultOsVariantValue: Set = new Set( + body.data.map( + (hostInfo: Record) => hostInfo.metadata.host.os.Ext.variant + ) + ); + expect(Array.from(resultOsVariantValue)).to.eql([variantValue]); + expect(body.page).to.eql(0); + expect(body.pageSize).to.eql(10); + }); + + it('metadata api should return the latest event for all the events for an endpoint', async () => { + const targetEndpointIp = '10.46.229.234'; + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + kuery: `HostDetails.host.ip:${targetEndpointIp} or host.ip:${targetEndpointIp}`, + }) + .expect(200); + expect(body.data.length).to.eql(1); + expect(body.total).to.eql(1); + const resultIp: string = body.data[0].metadata.host.ip.filter( + (ip: string) => ip === targetEndpointIp + ); + expect(resultIp).to.eql([targetEndpointIp]); + expect(body.data[0].metadata.event.created).to.eql(timestamp); + expect(body.page).to.eql(0); + expect(body.pageSize).to.eql(10); + }); + + it('metadata api should return the latest event for all the events where policy status is not success', async () => { + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + kuery: + 'not (HostDetails.Endpoint.policy.applied.status:success or Endpoint.policy.applied.status:success)', + }) + .expect(200); + const statuses: Set = new Set( + body.data.map( + (hostInfo: Record) => hostInfo.metadata.Endpoint.policy.applied.status + ) + ); + expect(statuses.size).to.eql(1); + expect(Array.from(statuses)).to.eql(['failure']); + }); + + it('metadata api should return the endpoint based on the elastic agent id, and status should be unhealthy', async () => { + const targetEndpointId = 'fc0ff548-feba-41b6-8367-65e8790d0eaf'; + const targetElasticAgentId = '023fa40c-411d-4188-a941-4147bfadd095'; + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + kuery: `HostDetails.elastic.agent.id:${targetElasticAgentId} or elastic.agent.id:${targetElasticAgentId}`, + }) + .expect(200); + expect(body.data.length).to.eql(1); + expect(body.total).to.eql(1); + const resultHostId: string = body.data[0].metadata.host.id; + const resultElasticAgentId: string = body.data[0].metadata.elastic.agent.id; + expect(resultHostId).to.eql(targetEndpointId); + expect(resultElasticAgentId).to.eql(targetElasticAgentId); + expect(body.data[0].metadata.event.created).to.eql(timestamp); + expect(body.data[0].host_status).to.eql('unhealthy'); + expect(body.page).to.eql(0); + expect(body.pageSize).to.eql(10); + }); + + it('metadata api should return all hosts when filter is empty string', async () => { + const { body } = await supertest + .get(HOST_METADATA_LIST_ROUTE) + .set('kbn-xsrf', 'xxx') + .query({ + kuery: '', + }) + .expect(200); + expect(body.data.length).to.eql(numberOfHostsInFixture); + expect(body.total).to.eql(numberOfHostsInFixture); + expect(body.page).to.eql(0); + expect(body.pageSize).to.eql(10); + }); + }); + }); }); }); }