[Cases] Add the ability to filter cases by date in the find and status endpoints (#128652)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2022-03-31 16:50:14 +03:00 committed by GitHub
parent e22deff7a6
commit 4246f3e631
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 588 additions and 18 deletions

View file

@ -34,6 +34,9 @@ Defaults to `OR`.
`fields`::
(Optional, array of strings) The fields in the entity to return in the response.
`from`::
(Optional, string) Returns only cases that were created after a specific date. The date must be specified as a <<kuery-query,KQL>> data range or date match expression. preview:[]
`owner`::
(Optional, string or array of strings) A filter to limit the retrieved cases to
a specific set of applications. Valid values are: `cases`, `observability`,
@ -78,6 +81,9 @@ Defaults to `desc`.
`tags`::
(Optional, string or array of strings) Filters the returned cases by tags.
`to`::
(Optional, string) Returns only cases that were created before a specific date. The date must be specified as a <<kuery-query,KQL>> data range or date match expression. preview:[]
=== Response code
`200`::

View file

@ -154,6 +154,10 @@ export const CasesFindRequestRt = rt.partial({
* The fields in the entity to return in the response
*/
fields: rt.union([rt.array(rt.string), rt.string]),
/**
* A KQL date. If used all cases created after (gte) the from date will be returned
*/
from: rt.string,
/**
* The page of objects to return
*/
@ -180,11 +184,17 @@ export const CasesFindRequestRt = rt.partial({
* The order to sort by
*/
sortOrder: rt.union([rt.literal('desc'), rt.literal('asc')]),
/**
* A KQL date. If used all cases created before (lte) the to date will be returned.
*/
to: rt.string,
/**
* The owner(s) to filter by. The user making the request must have privileges to retrieve cases of that
* ownership or they will be ignored. If no owner is included, then all ownership types will be included in the response
* that the user has access to.
*/
owner: rt.union([rt.array(rt.string), rt.string]),
});

View file

@ -28,6 +28,14 @@ export const CasesStatusResponseRt = rt.type({
});
export const CasesStatusRequestRt = rt.partial({
/**
* A KQL date. If used all cases created after (gte) the from date will be returned
*/
from: rt.string,
/**
* A KQL date. If used all cases created before (lte) the to date will be returned.
*/
to: rt.string,
/**
* The owner of the cases to retrieve the status stats from. If no owner is provided the stats for all cases
* that the user has access to will be returned.

View file

@ -51,6 +51,7 @@ export const SAVED_OBJECT_TYPES = [
*/
export const CASES_URL = '/api/cases' as const;
export const CASE_FIND_URL = `${CASES_URL}/_find` as const;
export const CASE_DETAILS_URL = `${CASES_URL}/{case_id}` as const;
export const CASE_CONFIGURE_URL = `${CASES_URL}/configure` as const;
export const CASE_CONFIGURE_DETAILS_URL = `${CASES_URL}/configure/{configuration_id}` as const;

View file

@ -7,16 +7,16 @@
import { httpServiceMock } from '../../../../../../src/core/public/mocks';
import { createClientAPI } from '.';
import { allCases, casesStatus } from '../../containers/mock';
describe('createClientAPI', () => {
const http = httpServiceMock.createStartContract({ basePath: '' });
const api = createClientAPI({ http });
beforeEach(() => {
jest.clearAllMocks();
});
describe('getRelatedCases', () => {
const http = httpServiceMock.createStartContract({ basePath: '' });
const api = createClientAPI({ http });
const res = [
{
id: 'test-id',
@ -43,4 +43,40 @@ describe('createClientAPI', () => {
});
});
});
describe('cases', () => {
describe('find', () => {
const http = httpServiceMock.createStartContract({ basePath: '' });
const api = createClientAPI({ http });
http.get.mockResolvedValue(allCases);
it('should return the correct response', async () => {
expect(await api.cases.find({ from: 'now-1d' })).toEqual(allCases);
});
it('should have been called with the correct path', async () => {
await api.cases.find({ perPage: 10 });
expect(http.get).toHaveBeenCalledWith('/api/cases/_find', {
query: { perPage: 10 },
});
});
});
describe('getAllCasesMetrics', () => {
const http = httpServiceMock.createStartContract({ basePath: '' });
const api = createClientAPI({ http });
http.get.mockResolvedValue(casesStatus);
it('should return the correct response', async () => {
expect(await api.cases.getAllCasesMetrics({ from: 'now-1d' })).toEqual(casesStatus);
});
it('should have been called with the correct path', async () => {
await api.cases.getAllCasesMetrics({ from: 'now-1d' });
expect(http.get).toHaveBeenCalledWith('/api/cases/status', {
query: { from: 'now-1d' },
});
});
});
});
});

View file

@ -6,7 +6,16 @@
*/
import { HttpStart } from 'kibana/public';
import { CasesByAlertId, CasesByAlertIDRequest, getCasesFromAlertsUrl } from '../../../common/api';
import {
CasesByAlertId,
CasesByAlertIDRequest,
CasesFindRequest,
getCasesFromAlertsUrl,
CasesResponse,
CasesStatusRequest,
CasesStatusResponse,
} from '../../../common/api';
import { CASE_FIND_URL, CASE_STATUS_URL } from '../../../common/constants';
import { CasesUiStart } from '../../types';
export const createClientAPI = ({ http }: { http: HttpStart }): CasesUiStart['api'] => {
@ -16,5 +25,11 @@ export const createClientAPI = ({ http }: { http: HttpStart }): CasesUiStart['ap
query: CasesByAlertIDRequest
): Promise<CasesByAlertId> =>
http.get<CasesByAlertId>(getCasesFromAlertsUrl(alertId), { query }),
cases: {
find: (query: CasesFindRequest): Promise<CasesResponse> =>
http.get<CasesResponse>(CASE_FIND_URL, { query }),
getAllCasesMetrics: (query: CasesStatusRequest): Promise<CasesStatusResponse> =>
http.get<CasesStatusResponse>(CASE_STATUS_URL, { query }),
},
};
};

View file

@ -10,6 +10,7 @@ import { CasesUiStart } from './types';
const apiMock: jest.Mocked<CasesUiStart['api']> = {
getRelatedCases: jest.fn(),
cases: { find: jest.fn(), getAllCasesMetrics: jest.fn() },
};
const uiMock: jest.Mocked<CasesUiStart['ui']> = {

View file

@ -23,6 +23,10 @@ import type { TriggersAndActionsUIPublicPluginStart as TriggersActionsStart } fr
import {
CasesByAlertId,
CasesByAlertIDRequest,
CasesFindRequest,
CasesResponse,
CasesStatusRequest,
CasesStatusResponse,
CommentRequestAlertType,
CommentRequestUserType,
} from '../common/api';
@ -74,6 +78,10 @@ export interface RenderAppProps {
export interface CasesUiStart {
api: {
getRelatedCases: (alertId: string, query: CasesByAlertIDRequest) => Promise<CasesByAlertId>;
cases: {
find: (query: CasesFindRequest) => Promise<CasesResponse>;
getAllCasesMetrics: (query: CasesStatusRequest) => Promise<CasesStatusResponse>;
};
};
ui: {
/**

View file

@ -54,6 +54,8 @@ export const find = async (
sortByField: queryParams.sortField,
status: queryParams.status,
owner: queryParams.owner,
from: queryParams.from,
to: queryParams.to,
};
const statusStatsOptions = constructQueryOptions({

View file

@ -41,6 +41,8 @@ export async function getStatusTotalsByType(
const options = constructQueryOptions({
owner: queryParams.owner,
from: queryParams.from,
to: queryParams.to,
authorizationFilter,
});

View file

@ -8,31 +8,32 @@
import { CaseConnector, ConnectorTypes } from '../../common/api';
import { newCase } from '../routes/api/__mocks__/request_responses';
import { transformNewCase } from '../common/utils';
import { sortToSnake } from './utils';
import { buildRangeFilter, sortToSnake } from './utils';
import { toElasticsearchQuery } from '@kbn/es-query';
describe('utils', () => {
describe('sortToSnake', () => {
it('it transforms status correctly', () => {
it('transforms status correctly', () => {
expect(sortToSnake('status')).toBe('status');
});
it('it transforms createdAt correctly', () => {
it('transforms createdAt correctly', () => {
expect(sortToSnake('createdAt')).toBe('created_at');
});
it('it transforms created_at correctly', () => {
it('transforms created_at correctly', () => {
expect(sortToSnake('created_at')).toBe('created_at');
});
it('it transforms closedAt correctly', () => {
it('transforms closedAt correctly', () => {
expect(sortToSnake('closedAt')).toBe('closed_at');
});
it('it transforms closed_at correctly', () => {
it('transforms closed_at correctly', () => {
expect(sortToSnake('closed_at')).toBe('closed_at');
});
it('it transforms default correctly', () => {
it('transforms default correctly', () => {
expect(sortToSnake('not-exist')).toBe('created_at');
});
});
@ -103,4 +104,154 @@ describe('utils', () => {
`);
});
});
describe('buildRangeFilter', () => {
it('returns undefined if both the from and or are undefined', () => {
const node = buildRangeFilter({});
expect(node).toBeFalsy();
});
it('returns undefined if both the from and or are null', () => {
// @ts-expect-error
const node = buildRangeFilter({ from: null, to: null });
expect(node).toBeFalsy();
});
it('returns undefined if the from is malformed', () => {
expect(() => buildRangeFilter({ from: '<' })).toThrowError(
'Invalid "from" and/or "to" query parameters'
);
});
it('returns undefined if the to is malformed', () => {
expect(() => buildRangeFilter({ to: '<' })).toThrowError(
'Invalid "from" and/or "to" query parameters'
);
});
it('creates a range filter with only the from correctly', () => {
const node = buildRangeFilter({ from: 'now-1M' });
expect(toElasticsearchQuery(node!)).toMatchInlineSnapshot(`
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"range": Object {
"cases.attributes.created_at": Object {
"gte": "now-1M",
},
},
},
],
},
}
`);
});
it('creates a range filter with only the to correctly', () => {
const node = buildRangeFilter({ to: 'now' });
expect(toElasticsearchQuery(node!)).toMatchInlineSnapshot(`
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"range": Object {
"cases.attributes.created_at": Object {
"lte": "now",
},
},
},
],
},
}
`);
});
it('creates a range filter correctly', () => {
const node = buildRangeFilter({ from: 'now-1M', to: 'now' });
expect(toElasticsearchQuery(node!)).toMatchInlineSnapshot(`
Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"range": Object {
"cases.attributes.created_at": Object {
"gte": "now-1M",
},
},
},
],
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"range": Object {
"cases.attributes.created_at": Object {
"lte": "now",
},
},
},
],
},
},
],
},
}
`);
});
it('creates a range filter with different field and saved object type provided', () => {
const node = buildRangeFilter({
from: 'now-1M',
to: 'now',
field: 'test',
savedObjectType: 'test-type',
});
expect(toElasticsearchQuery(node!)).toMatchInlineSnapshot(`
Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"range": Object {
"test-type.attributes.test": Object {
"gte": "now-1M",
},
},
},
],
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"range": Object {
"test-type.attributes.test": Object {
"lte": "now",
},
},
},
],
},
},
],
},
}
`);
});
});
});

View file

@ -183,6 +183,35 @@ export function stringToKueryNode(expression?: string): KueryNode | undefined {
return fromKueryExpression(expression);
}
export const buildRangeFilter = ({
from,
to,
field = 'created_at',
savedObjectType = CASE_SAVED_OBJECT,
}: {
from?: string;
to?: string;
field?: string;
savedObjectType?: string;
}): KueryNode | undefined => {
if (from == null && to == null) {
return;
}
try {
const fromKQL = from != null ? `${savedObjectType}.attributes.${field} >= ${from}` : undefined;
const toKQL = to != null ? `${savedObjectType}.attributes.${field} <= ${to}` : undefined;
const rangeKQLQuery = `${fromKQL != null ? fromKQL : ''} ${
fromKQL != null && toKQL != null ? 'and' : ''
} ${toKQL != null ? toKQL : ''}`;
return stringToKueryNode(rangeKQLQuery);
} catch (error) {
throw badRequest('Invalid "from" and/or "to" query parameters');
}
};
export const constructQueryOptions = ({
tags,
reporters,
@ -190,6 +219,8 @@ export const constructQueryOptions = ({
sortByField,
owner,
authorizationFilter,
from,
to,
}: {
tags?: string | string[];
reporters?: string | string[];
@ -197,6 +228,8 @@ export const constructQueryOptions = ({
sortByField?: string;
owner?: string | string[];
authorizationFilter?: KueryNode;
from?: string;
to?: string;
}): SavedObjectFindOptionsKueryNode => {
const kueryNodeExists = (filter: KueryNode | null | undefined): filter is KueryNode =>
filter != null;
@ -211,10 +244,15 @@ export const constructQueryOptions = ({
const ownerFilter = buildFilter({ filters: owner ?? [], field: OWNER_FIELD, operator: 'or' });
const statusFilter = status != null ? addStatusFilter({ status }) : undefined;
const rangeFilter = buildRangeFilter({ from, to });
const filters: KueryNode[] = [statusFilter, tagsFilter, reportersFilter, ownerFilter].filter(
kueryNodeExists
);
const filters: KueryNode[] = [
statusFilter,
tagsFilter,
reportersFilter,
rangeFilter,
ownerFilter,
].filter(kueryNodeExists);
const caseFilters = filters.length > 1 ? nodeBuilder.and(filters) : filters[0];

View file

@ -52,6 +52,7 @@ export default ({ getService }: FtrProviderContext): void => {
const es = getService('es');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
describe('find_cases', () => {
describe('basic tests', () => {
@ -478,6 +479,53 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
describe('range queries', () => {
before(async () => {
await kibanaServer.importExport.load(
'x-pack/test/functional/fixtures/kbn_archiver/cases/8.2.0/cases_various_dates.json'
);
});
after(async () => {
await kibanaServer.importExport.unload(
'x-pack/test/functional/fixtures/kbn_archiver/cases/8.2.0/cases_various_dates.json'
);
await deleteAllCaseItems(es);
});
it('returns all cases without a range filter', async () => {
const EXPECTED_CASES = 3;
const cases = await findCases({ supertest });
expect(cases.total).to.be(EXPECTED_CASES);
expect(cases.count_open_cases).to.be(EXPECTED_CASES);
expect(cases.cases.length).to.be(EXPECTED_CASES);
});
it('respects the range parameters', async () => {
const queries = [
{ expectedCases: 2, query: { from: '2022-03-16' } },
{ expectedCases: 2, query: { to: '2022-03-21' } },
{ expectedCases: 2, query: { from: '2022-03-15', to: '2022-03-21' } },
];
for (const query of queries) {
const cases = await findCases({
supertest,
query: query.query,
});
expect(cases.total).to.be(query.expectedCases);
expect(cases.count_open_cases).to.be(query.expectedCases);
expect(cases.cases.length).to.be(query.expectedCases);
}
});
it('returns a bad request on malformed parameter', async () => {
await findCases({ supertest, query: { from: '<' }, expectedHttpCode: 400 });
});
});
describe('rbac', () => {
afterEach(async () => {
await deleteAllCaseItems(es);
@ -717,6 +765,40 @@ export default ({ getService }: FtrProviderContext): void => {
// Only security solution cases are being returned
ensureSavedObjectIsAuthorized(res.cases, 1, ['securitySolutionFixture']);
});
describe('range queries', () => {
before(async () => {
await kibanaServer.importExport.load(
'x-pack/test/functional/fixtures/kbn_archiver/cases/8.2.0/cases_various_dates.json',
{ space: 'space1' }
);
});
after(async () => {
await kibanaServer.importExport.unload(
'x-pack/test/functional/fixtures/kbn_archiver/cases/8.2.0/cases_various_dates.json',
{ space: 'space1' }
);
await deleteAllCaseItems(es);
});
it('should respect the owner filter when using range queries', async () => {
const res = await findCases({
supertest: supertestWithoutAuth,
query: {
from: '2022-03-15',
to: '2022-03-21',
},
auth: {
user: secOnly,
space: 'space1',
},
});
// Only security solution cases are being returned
ensureSavedObjectIsAuthorized(res.cases, 1, ['securitySolutionFixture']);
});
});
});
});
};

View file

@ -34,12 +34,9 @@ import { assertWarningHeader } from '../../../../../common/lib/validation';
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
const es = getService('es');
const kibanaServer = getService('kibanaServer');
describe('get_status', () => {
afterEach(async () => {
await deleteAllCaseItems(es);
});
it('should return case statuses', async () => {
const [, inProgressCase, postedCase] = await Promise.all([
createCase(supertest, postCaseReq),
@ -74,7 +71,58 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
describe('range queries', () => {
before(async () => {
await deleteAllCaseItems(es);
await kibanaServer.importExport.load(
'x-pack/test/functional/fixtures/kbn_archiver/cases/8.2.0/cases_various_dates.json'
);
});
after(async () => {
await kibanaServer.importExport.unload(
'x-pack/test/functional/fixtures/kbn_archiver/cases/8.2.0/cases_various_dates.json'
);
await deleteAllCaseItems(es);
});
it('returns all cases without a range filter', async () => {
const statuses = await getAllCasesStatuses({ supertest });
expect(statuses).to.eql({
count_open_cases: 3,
count_closed_cases: 0,
count_in_progress_cases: 0,
});
});
it('respects the range parameters', async () => {
const queries = [
{ expectedCases: 2, query: { from: '2022-03-16' } },
{ expectedCases: 2, query: { to: '2022-03-21' } },
{ expectedCases: 2, query: { from: '2022-03-15', to: '2022-03-21' } },
];
for (const query of queries) {
const statuses = await getAllCasesStatuses({ supertest, query: query.query });
expect(statuses).to.eql({
count_open_cases: query.expectedCases,
count_closed_cases: 0,
count_in_progress_cases: 0,
});
}
});
it('returns a bad request on malformed parameter', async () => {
await getAllCasesStatuses({ supertest, query: { from: '<' }, expectedHttpCode: 400 });
});
});
describe('rbac', () => {
afterEach(async () => {
await deleteAllCaseItems(es);
});
const supertestWithoutAuth = getService('supertestWithoutAuth');
it('should return the correct status stats', async () => {
@ -183,6 +231,43 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
}
describe('range queries', () => {
before(async () => {
await kibanaServer.importExport.load(
'x-pack/test/functional/fixtures/kbn_archiver/cases/8.2.0/cases_various_dates.json',
{ space: 'space1' }
);
});
after(async () => {
await kibanaServer.importExport.unload(
'x-pack/test/functional/fixtures/kbn_archiver/cases/8.2.0/cases_various_dates.json',
{ space: 'space1' }
);
await deleteAllCaseItems(es);
});
it('should respect the owner filter when using range queries', async () => {
const res = await getAllCasesStatuses({
supertest: supertestWithoutAuth,
query: {
from: '2022-03-15',
to: '2022-03-21',
},
auth: {
user: secOnly,
space: 'space1',
},
});
expect(res).to.eql({
count_open_cases: 1,
count_closed_cases: 0,
count_in_progress_cases: 0,
});
});
});
});
describe('deprecations', () => {

View file

@ -0,0 +1,125 @@
{
"attributes": {
"closed_at": null,
"closed_by": null,
"connector": {
"fields": null,
"name": "none",
"type": ".none"
},
"created_at": "2022-03-15T10:16:56.252Z",
"created_by": {
"email": "",
"full_name": "",
"username": "cnasikas"
},
"description": "test",
"external_service": null,
"owner": "securitySolutionFixture",
"settings": {
"syncAlerts": false
},
"status": "open",
"tags": [],
"title": "stack",
"updated_at": "2022-03-29T10:33:09.754Z",
"updated_by": {
"email": "",
"full_name": "",
"username": "cnasikas"
}
},
"coreMigrationVersion": "8.2.0",
"id": "1537b380-a512-11ec-b94f-85999e89e434",
"migrationVersion": {
"cases": "8.1.0"
},
"references": [],
"type": "cases",
"updated_at": "2022-03-29T10:33:09.754Z",
"version": "WzE2OTYyNCwxNF0="
}
{
"attributes": {
"closed_at": null,
"closed_by": null,
"connector": {
"fields": null,
"name": "none",
"type": ".none"
},
"created_at": "2022-03-20T10:16:56.252Z",
"created_by": {
"email": "",
"full_name": "",
"username": "cnasikas"
},
"description": "test 2",
"external_service": null,
"owner": "observabilityFixture",
"settings": {
"syncAlerts": false
},
"status": "open",
"tags": [],
"title": "stack",
"updated_at": "2022-03-29T10:33:09.754Z",
"updated_by": {
"email": "",
"full_name": "",
"username": "cnasikas"
}
},
"coreMigrationVersion": "8.2.0",
"id": "3537b580-a512-11ec-b94f-85979e89e434",
"migrationVersion": {
"cases": "8.1.0"
},
"references": [],
"type": "cases",
"updated_at": "2022-03-29T10:33:09.754Z",
"version": "WzE2OTYyNCwxNF0="
}
{
"attributes": {
"closed_at": null,
"closed_by": null,
"connector": {
"fields": null,
"name": "none",
"type": ".none"
},
"created_at": "2022-03-25T10:16:56.252Z",
"created_by": {
"email": "",
"full_name": "",
"username": "cnasikas"
},
"description": "test 2",
"external_service": null,
"owner": "securitySolutionFixture",
"settings": {
"syncAlerts": false
},
"status": "open",
"tags": [],
"title": "stack",
"updated_at": "2022-03-29T10:33:09.754Z",
"updated_by": {
"email": "",
"full_name": "",
"username": "cnasikas"
}
},
"coreMigrationVersion": "8.2.0",
"id": "4537b380-a512-11ec-b92f-859b9e89e434",
"migrationVersion": {
"cases": "8.1.0"
},
"references": [],
"type": "cases",
"updated_at": "2022-03-29T10:33:09.754Z",
"version": "WzE2OTYyNCwxNF0="
}