diff --git a/x-pack/plugins/lists/common/schemas/request/delete_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/delete_list_schema.ts index fd6aa5b85f81ae..6f6fc7a9ea33c2 100644 --- a/x-pack/plugins/lists/common/schemas/request/delete_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/delete_list_schema.ts @@ -17,3 +17,4 @@ export const deleteListSchema = t.exact( ); export type DeleteListSchema = t.TypeOf; +export type DeleteListSchemaEncoded = t.OutputOf; diff --git a/x-pack/plugins/lists/common/schemas/request/export_list_item_query_schema.ts b/x-pack/plugins/lists/common/schemas/request/export_list_item_query_schema.ts index 14b201bf8089de..58092ffc563b1d 100644 --- a/x-pack/plugins/lists/common/schemas/request/export_list_item_query_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/export_list_item_query_schema.ts @@ -18,3 +18,4 @@ export const exportListItemQuerySchema = t.exact( ); export type ExportListItemQuerySchema = t.TypeOf; +export type ExportListItemQuerySchemaEncoded = t.OutputOf; diff --git a/x-pack/plugins/lists/common/schemas/request/find_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/find_list_schema.ts index c29ab4f5360ddb..212232f6bc9c11 100644 --- a/x-pack/plugins/lists/common/schemas/request/find_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/find_list_schema.ts @@ -9,7 +9,6 @@ import * as t from 'io-ts'; import { cursor, filter, sort_field, sort_order } from '../common/schemas'; -import { RequiredKeepUndefined } from '../../types'; import { StringToPositiveNumber } from '../types/string_to_positive_number'; export const findListSchema = t.exact( @@ -23,6 +22,5 @@ export const findListSchema = t.exact( }) ); -export type FindListSchemaPartial = t.TypeOf; - -export type FindListSchema = RequiredKeepUndefined>; +export type FindListSchema = t.TypeOf; +export type FindListSchemaEncoded = t.OutputOf; diff --git a/x-pack/plugins/lists/common/schemas/request/import_list_item_query_schema.ts b/x-pack/plugins/lists/common/schemas/request/import_list_item_query_schema.ts index 73d9a53a41e4f1..b37de61d0c2c34 100644 --- a/x-pack/plugins/lists/common/schemas/request/import_list_item_query_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/import_list_item_query_schema.ts @@ -9,11 +9,11 @@ import * as t from 'io-ts'; import { list_id, type } from '../common/schemas'; -import { Identity, RequiredKeepUndefined } from '../../types'; +import { Identity } from '../../types'; export const importListItemQuerySchema = t.exact(t.partial({ list_id, type })); export type ImportListItemQuerySchemaPartial = Identity>; -export type ImportListItemQuerySchema = RequiredKeepUndefined< - t.TypeOf ->; + +export type ImportListItemQuerySchema = t.TypeOf; +export type ImportListItemQuerySchemaEncoded = t.OutputOf; diff --git a/x-pack/plugins/lists/common/schemas/request/import_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/import_list_item_schema.ts index ee6a2aa0b339a7..7370eecf690c76 100644 --- a/x-pack/plugins/lists/common/schemas/request/import_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/import_list_item_schema.ts @@ -17,3 +17,4 @@ export const importListItemSchema = t.exact( ); export type ImportListItemSchema = t.TypeOf; +export type ImportListItemSchemaEncoded = t.OutputOf; diff --git a/x-pack/plugins/lists/common/siem_common_deps.ts b/x-pack/plugins/lists/common/siem_common_deps.ts index b1bb7d8aace365..dccc548985e779 100644 --- a/x-pack/plugins/lists/common/siem_common_deps.ts +++ b/x-pack/plugins/lists/common/siem_common_deps.ts @@ -9,5 +9,5 @@ export { DefaultUuid } from '../../security_solution/common/detection_engine/sch export { DefaultStringArray } from '../../security_solution/common/detection_engine/schemas/types/default_string_array'; export { exactCheck } from '../../security_solution/common/exact_check'; export { getPaths, foldLeftRight } from '../../security_solution/common/test_utils'; -export { validate } from '../../security_solution/common/validate'; +export { validate, validateEither } from '../../security_solution/common/validate'; export { formatErrors } from '../../security_solution/common/format_errors'; diff --git a/x-pack/plugins/lists/public/common/fp_utils.test.ts b/x-pack/plugins/lists/public/common/fp_utils.test.ts new file mode 100644 index 00000000000000..79042f4f9a72f3 --- /dev/null +++ b/x-pack/plugins/lists/public/common/fp_utils.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { tryCatch } from 'fp-ts/lib/TaskEither'; + +import { toPromise } from './fp_utils'; + +describe('toPromise', () => { + it('rejects with left if TaskEither is left', async () => { + const task = tryCatch(() => Promise.reject(new Error('whoops')), String); + + await expect(toPromise(task)).rejects.toEqual('Error: whoops'); + }); + + it('resolves with right if TaskEither is right', async () => { + const task = tryCatch(() => Promise.resolve('success'), String); + + await expect(toPromise(task)).resolves.toEqual('success'); + }); +}); diff --git a/x-pack/plugins/lists/public/common/fp_utils.ts b/x-pack/plugins/lists/public/common/fp_utils.ts new file mode 100644 index 00000000000000..04e10338794762 --- /dev/null +++ b/x-pack/plugins/lists/public/common/fp_utils.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { TaskEither } from 'fp-ts/lib/TaskEither'; +import { fold } from 'fp-ts/lib/Either'; + +export const toPromise = async (taskEither: TaskEither): Promise => + pipe( + await taskEither(), + fold( + (e) => Promise.reject(e), + (a) => Promise.resolve(a) + ) + ); diff --git a/x-pack/plugins/lists/public/common/hooks/use_async_task.test.ts b/x-pack/plugins/lists/public/common/hooks/use_async_task.test.ts new file mode 100644 index 00000000000000..af3aa60cfa5065 --- /dev/null +++ b/x-pack/plugins/lists/public/common/hooks/use_async_task.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import { useAsyncTask } from './use_async_task'; + +describe('useAsyncTask', () => { + let task: jest.Mock; + + beforeEach(() => { + task = jest.fn().mockResolvedValue('resolved value'); + }); + + it('does not invoke task if start was not called', () => { + renderHook(() => useAsyncTask(task)); + expect(task).not.toHaveBeenCalled(); + }); + + it('invokes the task when start is called', async () => { + const { result, waitForNextUpdate } = renderHook(() => useAsyncTask(task)); + + act(() => { + result.current.start({}); + }); + await waitForNextUpdate(); + + expect(task).toHaveBeenCalled(); + }); + + it('invokes the task with a signal and start args', async () => { + const { result, waitForNextUpdate } = renderHook(() => useAsyncTask(task)); + + act(() => { + result.current.start({ + arg1: 'value1', + arg2: 'value2', + }); + }); + await waitForNextUpdate(); + + expect(task).toHaveBeenCalledWith(expect.any(AbortController), { + arg1: 'value1', + arg2: 'value2', + }); + }); + + it('populates result with the resolved value of the task', async () => { + const { result, waitForNextUpdate } = renderHook(() => useAsyncTask(task)); + + act(() => { + result.current.start({}); + }); + await waitForNextUpdate(); + + expect(result.current.result).toEqual('resolved value'); + expect(result.current.error).toBeUndefined(); + }); + + it('populates error if task rejects', async () => { + task.mockRejectedValue(new Error('whoops')); + const { result, waitForNextUpdate } = renderHook(() => useAsyncTask(task)); + + act(() => { + result.current.start({}); + }); + await waitForNextUpdate(); + + expect(result.current.result).toBeUndefined(); + expect(result.current.error).toEqual(new Error('whoops')); + }); + + it('populates the loading state while the task is pending', async () => { + let resolve: () => void; + task.mockImplementation(() => new Promise((_resolve) => (resolve = _resolve))); + + const { result, waitForNextUpdate } = renderHook(() => useAsyncTask(task)); + + act(() => { + result.current.start({}); + }); + + expect(result.current.loading).toBe(true); + + act(() => resolve()); + await waitForNextUpdate(); + + expect(result.current.loading).toBe(false); + }); +}); diff --git a/x-pack/plugins/lists/public/common/hooks/use_async_task.ts b/x-pack/plugins/lists/public/common/hooks/use_async_task.ts new file mode 100644 index 00000000000000..f767e9333c2348 --- /dev/null +++ b/x-pack/plugins/lists/public/common/hooks/use_async_task.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback, useRef } from 'react'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; + +// Params can be generalized to a ...rest parameter extending unknown[] once https://github.com/microsoft/TypeScript/pull/39094 is available. +// for now, the task must still receive unknown as a second argument, and an argument must be passed to start() +export type UseAsyncTask = ( + task: (...args: [AbortController, Params]) => Promise +) => AsyncTask; + +export interface AsyncTask { + start: (params: Params) => void; + abort: () => void; + loading: boolean; + error: Error | undefined; + result: Result | undefined; +} + +/** + * + * @param task Async function receiving an AbortController and optional arguments + * + * @returns An {@link AsyncTask} containing the underlying task's state along with start/abort helpers + */ +export const useAsyncTask: UseAsyncTask = (task) => { + const ctrl = useRef(new AbortController()); + const abort = useCallback((): void => { + ctrl.current.abort(); + }, []); + + // @ts-ignore typings are incorrect, see: https://github.com/streamich/react-use/pull/589 + const [state, initiator] = useAsyncFn(task, [task]); + + const start = useCallback( + (args) => { + ctrl.current = new AbortController(); + initiator(ctrl.current, args); + }, + [initiator] + ); + + return { abort, error: state.error, loading: state.loading, result: state.value, start }; +}; diff --git a/x-pack/plugins/lists/public/index.tsx b/x-pack/plugins/lists/public/index.tsx index 71187273c731cd..1ea24123ccb9ac 100644 --- a/x-pack/plugins/lists/public/index.tsx +++ b/x-pack/plugins/lists/public/index.tsx @@ -3,11 +3,16 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + // Exports to be shared with plugins export { useApi } from './exceptions/hooks/use_api'; export { usePersistExceptionItem } from './exceptions/hooks/persist_exception_item'; export { usePersistExceptionList } from './exceptions/hooks/persist_exception_list'; export { useExceptionList } from './exceptions/hooks/use_exception_list'; +export { useFindLists } from './lists/hooks/use_find_lists'; +export { useImportList } from './lists/hooks/use_import_list'; +export { useDeleteList } from './lists/hooks/use_delete_list'; +export { useExportList } from './lists/hooks/use_export_list'; export { ExceptionList, ExceptionIdentifiers, diff --git a/x-pack/plugins/lists/public/lists/api.test.ts b/x-pack/plugins/lists/public/lists/api.test.ts new file mode 100644 index 00000000000000..38556e2eabc18c --- /dev/null +++ b/x-pack/plugins/lists/public/lists/api.test.ts @@ -0,0 +1,331 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpFetchOptions } from '../../../../../src/core/public'; +import { httpServiceMock } from '../../../../../src/core/public/mocks'; +import { getListResponseMock } from '../../common/schemas/response/list_schema.mock'; +import { getFoundListSchemaMock } from '../../common/schemas/response/found_list_schema.mock'; + +import { deleteList, exportList, findLists, importList } from './api'; +import { + ApiPayload, + DeleteListParams, + ExportListParams, + FindListsParams, + ImportListParams, +} from './types'; + +describe('Value Lists API', () => { + let httpMock: ReturnType; + + beforeEach(() => { + httpMock = httpServiceMock.createStartContract(); + }); + + describe('deleteList', () => { + beforeEach(() => { + httpMock.fetch.mockResolvedValue(getListResponseMock()); + }); + + it('DELETEs specifying the id as a query parameter', async () => { + const abortCtrl = new AbortController(); + const payload: ApiPayload = { id: 'list-id' }; + await deleteList({ + http: httpMock, + ...payload, + signal: abortCtrl.signal, + }); + + expect(httpMock.fetch).toHaveBeenCalledWith( + '/api/lists', + expect.objectContaining({ + method: 'DELETE', + query: { id: 'list-id' }, + }) + ); + }); + + it('rejects with an error if request payload is invalid (and does not make API call)', async () => { + const abortCtrl = new AbortController(); + const payload: Omit, 'id'> & { + id: number; + } = { id: 23 }; + + await expect( + deleteList({ + http: httpMock, + ...((payload as unknown) as ApiPayload), + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "23" supplied to "id"'); + expect(httpMock.fetch).not.toHaveBeenCalled(); + }); + + it('rejects with an error if response payload is invalid', async () => { + const abortCtrl = new AbortController(); + const payload: ApiPayload = { id: 'list-id' }; + const badResponse = { ...getListResponseMock(), id: undefined }; + httpMock.fetch.mockResolvedValue(badResponse); + + await expect( + deleteList({ + http: httpMock, + ...payload, + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "undefined" supplied to "id"'); + }); + }); + + describe('findLists', () => { + beforeEach(() => { + httpMock.fetch.mockResolvedValue(getFoundListSchemaMock()); + }); + + it('GETs from the lists endpoint', async () => { + const abortCtrl = new AbortController(); + await findLists({ + http: httpMock, + pageIndex: 1, + pageSize: 10, + signal: abortCtrl.signal, + }); + + expect(httpMock.fetch).toHaveBeenCalledWith( + '/api/lists/_find', + expect.objectContaining({ + method: 'GET', + }) + ); + }); + + it('sends pagination as query parameters', async () => { + const abortCtrl = new AbortController(); + await findLists({ + http: httpMock, + pageIndex: 1, + pageSize: 10, + signal: abortCtrl.signal, + }); + + expect(httpMock.fetch).toHaveBeenCalledWith( + '/api/lists/_find', + expect.objectContaining({ + query: { page: 1, per_page: 10 }, + }) + ); + }); + + it('rejects with an error if request payload is invalid (and does not make API call)', async () => { + const abortCtrl = new AbortController(); + const payload: ApiPayload = { pageIndex: 10, pageSize: 0 }; + + await expect( + findLists({ + http: httpMock, + ...payload, + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "0" supplied to "per_page"'); + expect(httpMock.fetch).not.toHaveBeenCalled(); + }); + + it('rejects with an error if response payload is invalid', async () => { + const abortCtrl = new AbortController(); + const payload: ApiPayload = { pageIndex: 1, pageSize: 10 }; + const badResponse = { ...getFoundListSchemaMock(), cursor: undefined }; + httpMock.fetch.mockResolvedValue(badResponse); + + await expect( + findLists({ + http: httpMock, + ...payload, + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "undefined" supplied to "cursor"'); + }); + }); + + describe('importList', () => { + beforeEach(() => { + httpMock.fetch.mockResolvedValue(getListResponseMock()); + }); + + it('POSTs the file', async () => { + const abortCtrl = new AbortController(); + const file = new File([], 'name'); + + await importList({ + file, + http: httpMock, + listId: 'my_list', + signal: abortCtrl.signal, + type: 'keyword', + }); + expect(httpMock.fetch).toHaveBeenCalledWith( + '/api/lists/items/_import', + expect.objectContaining({ + method: 'POST', + }) + ); + + // httpmock's fetch signature is inferred incorrectly + const [[, { body }]] = (httpMock.fetch.mock.calls as unknown) as Array< + [unknown, HttpFetchOptions] + >; + const actualFile = (body as FormData).get('file'); + expect(actualFile).toEqual(file); + }); + + it('sends type and id as query parameters', async () => { + const abortCtrl = new AbortController(); + const file = new File([], 'name'); + + await importList({ + file, + http: httpMock, + listId: 'my_list', + signal: abortCtrl.signal, + type: 'keyword', + }); + + expect(httpMock.fetch).toHaveBeenCalledWith( + '/api/lists/items/_import', + expect.objectContaining({ + query: { list_id: 'my_list', type: 'keyword' }, + }) + ); + }); + + it('rejects with an error if request body is invalid (and does not make API call)', async () => { + const abortCtrl = new AbortController(); + const payload: ApiPayload = { + file: (undefined as unknown) as File, + listId: 'list-id', + type: 'ip', + }; + + await expect( + importList({ + http: httpMock, + ...payload, + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "undefined" supplied to "file"'); + expect(httpMock.fetch).not.toHaveBeenCalled(); + }); + + it('rejects with an error if request params are invalid (and does not make API call)', async () => { + const abortCtrl = new AbortController(); + const file = new File([], 'name'); + const payload: ApiPayload = { + file, + listId: 'list-id', + type: 'other' as 'ip', + }; + + await expect( + importList({ + http: httpMock, + ...payload, + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "other" supplied to "type"'); + expect(httpMock.fetch).not.toHaveBeenCalled(); + }); + + it('rejects with an error if response payload is invalid', async () => { + const abortCtrl = new AbortController(); + const file = new File([], 'name'); + const payload: ApiPayload = { + file, + listId: 'list-id', + type: 'ip', + }; + const badResponse = { ...getListResponseMock(), id: undefined }; + httpMock.fetch.mockResolvedValue(badResponse); + + await expect( + importList({ + http: httpMock, + ...payload, + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "undefined" supplied to "id"'); + }); + }); + + describe('exportList', () => { + beforeEach(() => { + httpMock.fetch.mockResolvedValue(getListResponseMock()); + }); + + it('POSTs to the export endpoint', async () => { + const abortCtrl = new AbortController(); + + await exportList({ + http: httpMock, + listId: 'my_list', + signal: abortCtrl.signal, + }); + expect(httpMock.fetch).toHaveBeenCalledWith( + '/api/lists/items/_export', + expect.objectContaining({ + method: 'POST', + }) + ); + }); + + it('sends type and id as query parameters', async () => { + const abortCtrl = new AbortController(); + + await exportList({ + http: httpMock, + listId: 'my_list', + signal: abortCtrl.signal, + }); + expect(httpMock.fetch).toHaveBeenCalledWith( + '/api/lists/items/_export', + expect.objectContaining({ + query: { list_id: 'my_list' }, + }) + ); + }); + + it('rejects with an error if request params are invalid (and does not make API call)', async () => { + const abortCtrl = new AbortController(); + const payload: ApiPayload = { + listId: (23 as unknown) as string, + }; + + await expect( + exportList({ + http: httpMock, + ...payload, + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "23" supplied to "list_id"'); + expect(httpMock.fetch).not.toHaveBeenCalled(); + }); + + it('rejects with an error if response payload is invalid', async () => { + const abortCtrl = new AbortController(); + const payload: ApiPayload = { + listId: 'list-id', + }; + const badResponse = { ...getListResponseMock(), id: undefined }; + httpMock.fetch.mockResolvedValue(badResponse); + + await expect( + exportList({ + http: httpMock, + ...payload, + signal: abortCtrl.signal, + }) + ).rejects.toEqual('Invalid value "undefined" supplied to "id"'); + }); + }); +}); diff --git a/x-pack/plugins/lists/public/lists/api.ts b/x-pack/plugins/lists/public/lists/api.ts new file mode 100644 index 00000000000000..d615239f4eb010 --- /dev/null +++ b/x-pack/plugins/lists/public/lists/api.ts @@ -0,0 +1,173 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { chain, fromEither, map, tryCatch } from 'fp-ts/lib/TaskEither'; +import { flow } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { + DeleteListSchemaEncoded, + ExportListItemQuerySchemaEncoded, + FindListSchemaEncoded, + FoundListSchema, + ImportListItemQuerySchemaEncoded, + ImportListItemSchemaEncoded, + ListSchema, + deleteListSchema, + exportListItemQuerySchema, + findListSchema, + foundListSchema, + importListItemQuerySchema, + importListItemSchema, + listSchema, +} from '../../common/schemas'; +import { LIST_ITEM_URL, LIST_URL } from '../../common/constants'; +import { validateEither } from '../../common/siem_common_deps'; +import { toPromise } from '../common/fp_utils'; + +import { + ApiParams, + DeleteListParams, + ExportListParams, + FindListsParams, + ImportListParams, +} from './types'; + +const findLists = async ({ + http, + cursor, + page, + per_page, + signal, +}: ApiParams & FindListSchemaEncoded): Promise => { + return http.fetch(`${LIST_URL}/_find`, { + method: 'GET', + query: { + cursor, + page, + per_page, + }, + signal, + }); +}; + +const findListsWithValidation = async ({ + http, + pageIndex, + pageSize, + signal, +}: FindListsParams): Promise => + pipe( + { + page: String(pageIndex), + per_page: String(pageSize), + }, + (payload) => fromEither(validateEither(findListSchema, payload)), + chain((payload) => tryCatch(() => findLists({ http, signal, ...payload }), String)), + chain((response) => fromEither(validateEither(foundListSchema, response))), + flow(toPromise) + ); + +export { findListsWithValidation as findLists }; + +const importList = async ({ + file, + http, + list_id, + type, + signal, +}: ApiParams & ImportListItemSchemaEncoded & ImportListItemQuerySchemaEncoded): Promise< + ListSchema +> => { + const formData = new FormData(); + formData.append('file', file as Blob); + + return http.fetch(`${LIST_ITEM_URL}/_import`, { + body: formData, + headers: { 'Content-Type': undefined }, + method: 'POST', + query: { list_id, type }, + signal, + }); +}; + +const importListWithValidation = async ({ + file, + http, + listId, + type, + signal, +}: ImportListParams): Promise => + pipe( + { + list_id: listId, + type, + }, + (query) => fromEither(validateEither(importListItemQuerySchema, query)), + chain((query) => + pipe( + fromEither(validateEither(importListItemSchema, { file })), + map((body) => ({ ...body, ...query })) + ) + ), + chain((payload) => tryCatch(() => importList({ http, signal, ...payload }), String)), + chain((response) => fromEither(validateEither(listSchema, response))), + flow(toPromise) + ); + +export { importListWithValidation as importList }; + +const deleteList = async ({ + http, + id, + signal, +}: ApiParams & DeleteListSchemaEncoded): Promise => + http.fetch(LIST_URL, { + method: 'DELETE', + query: { id }, + signal, + }); + +const deleteListWithValidation = async ({ + http, + id, + signal, +}: DeleteListParams): Promise => + pipe( + { id }, + (payload) => fromEither(validateEither(deleteListSchema, payload)), + chain((payload) => tryCatch(() => deleteList({ http, signal, ...payload }), String)), + chain((response) => fromEither(validateEither(listSchema, response))), + flow(toPromise) + ); + +export { deleteListWithValidation as deleteList }; + +const exportList = async ({ + http, + list_id, + signal, +}: ApiParams & ExportListItemQuerySchemaEncoded): Promise => + http.fetch(`${LIST_ITEM_URL}/_export`, { + method: 'POST', + query: { list_id }, + signal, + }); + +const exportListWithValidation = async ({ + http, + listId, + signal, +}: ExportListParams): Promise => + pipe( + { list_id: listId }, + (payload) => fromEither(validateEither(exportListItemQuerySchema, payload)), + chain((payload) => tryCatch(() => exportList({ http, signal, ...payload }), String)), + chain((response) => fromEither(validateEither(listSchema, response))), + flow(toPromise) + ); + +export { exportListWithValidation as exportList }; diff --git a/x-pack/plugins/lists/public/lists/hooks/use_delete_list.test.ts b/x-pack/plugins/lists/public/lists/hooks/use_delete_list.test.ts new file mode 100644 index 00000000000000..6262c553dfd525 --- /dev/null +++ b/x-pack/plugins/lists/public/lists/hooks/use_delete_list.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import * as Api from '../api'; +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; +import { getListResponseMock } from '../../../common/schemas/response/list_schema.mock'; + +import { useDeleteList } from './use_delete_list'; + +jest.mock('../api'); + +describe('useDeleteList', () => { + let httpMock: ReturnType; + + beforeEach(() => { + httpMock = httpServiceMock.createStartContract(); + (Api.deleteList as jest.Mock).mockResolvedValue(getListResponseMock()); + }); + + it('invokes Api.deleteList', async () => { + const { result, waitForNextUpdate } = renderHook(() => useDeleteList()); + act(() => { + result.current.start({ http: httpMock, id: 'list' }); + }); + await waitForNextUpdate(); + + expect(Api.deleteList).toHaveBeenCalledWith( + expect.objectContaining({ http: httpMock, id: 'list' }) + ); + }); +}); diff --git a/x-pack/plugins/lists/public/lists/hooks/use_delete_list.ts b/x-pack/plugins/lists/public/lists/hooks/use_delete_list.ts new file mode 100644 index 00000000000000..0f1f6facdd7c4b --- /dev/null +++ b/x-pack/plugins/lists/public/lists/hooks/use_delete_list.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useAsyncTask } from '../../common/hooks/use_async_task'; +import { DeleteListParams } from '../types'; +import { deleteList } from '../api'; + +export type DeleteListTaskArgs = Omit; + +const deleteListsTask = ( + { signal }: AbortController, + args: DeleteListTaskArgs +): ReturnType => deleteList({ signal, ...args }); + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const useDeleteList = () => useAsyncTask(deleteListsTask); diff --git a/x-pack/plugins/lists/public/lists/hooks/use_export_list.test.ts b/x-pack/plugins/lists/public/lists/hooks/use_export_list.test.ts new file mode 100644 index 00000000000000..2eca0fd11b21a9 --- /dev/null +++ b/x-pack/plugins/lists/public/lists/hooks/use_export_list.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import * as Api from '../api'; +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; + +import { useExportList } from './use_export_list'; + +jest.mock('../api'); + +describe('useExportList', () => { + let httpMock: ReturnType; + + beforeEach(() => { + httpMock = httpServiceMock.createStartContract(); + (Api.exportList as jest.Mock).mockResolvedValue(new Blob()); + }); + + it('invokes Api.exportList', async () => { + const { result, waitForNextUpdate } = renderHook(() => useExportList()); + act(() => { + result.current.start({ http: httpMock, listId: 'list' }); + }); + await waitForNextUpdate(); + + expect(Api.exportList).toHaveBeenCalledWith( + expect.objectContaining({ http: httpMock, listId: 'list' }) + ); + }); +}); diff --git a/x-pack/plugins/lists/public/lists/hooks/use_export_list.ts b/x-pack/plugins/lists/public/lists/hooks/use_export_list.ts new file mode 100644 index 00000000000000..41efde939ead40 --- /dev/null +++ b/x-pack/plugins/lists/public/lists/hooks/use_export_list.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useAsyncTask } from '../../common/hooks/use_async_task'; +import { ExportListParams } from '../types'; +import { exportList } from '../api'; + +export type ExportListTaskArgs = Omit; + +const exportListTask = ( + { signal }: AbortController, + args: ExportListTaskArgs +): ReturnType => exportList({ signal, ...args }); + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const useExportList = () => useAsyncTask(exportListTask); diff --git a/x-pack/plugins/lists/public/lists/hooks/use_find_lists.test.ts b/x-pack/plugins/lists/public/lists/hooks/use_find_lists.test.ts new file mode 100644 index 00000000000000..0d63acbe0bd2c7 --- /dev/null +++ b/x-pack/plugins/lists/public/lists/hooks/use_find_lists.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import * as Api from '../api'; +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; +import { getFoundListSchemaMock } from '../../../common/schemas/response/found_list_schema.mock'; + +import { useFindLists } from './use_find_lists'; + +jest.mock('../api'); + +describe('useFindLists', () => { + let httpMock: ReturnType; + + beforeEach(() => { + httpMock = httpServiceMock.createStartContract(); + (Api.findLists as jest.Mock).mockResolvedValue(getFoundListSchemaMock()); + }); + + it('invokes Api.findLists', async () => { + const { result, waitForNextUpdate } = renderHook(() => useFindLists()); + act(() => { + result.current.start({ http: httpMock, pageIndex: 1, pageSize: 10 }); + }); + await waitForNextUpdate(); + + expect(Api.findLists).toHaveBeenCalledWith( + expect.objectContaining({ http: httpMock, pageIndex: 1, pageSize: 10 }) + ); + }); +}); diff --git a/x-pack/plugins/lists/public/lists/hooks/use_find_lists.ts b/x-pack/plugins/lists/public/lists/hooks/use_find_lists.ts new file mode 100644 index 00000000000000..d50a16855a547e --- /dev/null +++ b/x-pack/plugins/lists/public/lists/hooks/use_find_lists.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useAsyncTask } from '../../common/hooks/use_async_task'; +import { FindListsParams } from '../types'; +import { findLists } from '../api'; + +export type FindListsTaskArgs = Omit; + +const findListsTask = ( + { signal }: AbortController, + args: FindListsTaskArgs +): ReturnType => findLists({ signal, ...args }); + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const useFindLists = () => useAsyncTask(findListsTask); diff --git a/x-pack/plugins/lists/public/lists/hooks/use_import_list.test.ts b/x-pack/plugins/lists/public/lists/hooks/use_import_list.test.ts new file mode 100644 index 00000000000000..00a8b7f3206b05 --- /dev/null +++ b/x-pack/plugins/lists/public/lists/hooks/use_import_list.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; +import { getListResponseMock } from '../../../common/schemas/response/list_schema.mock'; +import * as Api from '../api'; + +import { useImportList } from './use_import_list'; + +jest.mock('../api'); + +describe('useImportList', () => { + let httpMock: ReturnType; + + beforeEach(() => { + httpMock = httpServiceMock.createStartContract(); + (Api.importList as jest.Mock).mockResolvedValue(getListResponseMock()); + }); + + it('does not invoke importList if start was not called', () => { + renderHook(() => useImportList()); + expect(Api.importList).not.toHaveBeenCalled(); + }); + + it('invokes Api.importList', async () => { + const fileMock = ('my file' as unknown) as File; + + const { result, waitForNextUpdate } = renderHook(() => useImportList()); + + act(() => { + result.current.start({ + file: fileMock, + http: httpMock, + listId: 'my_list_id', + type: 'keyword', + }); + }); + await waitForNextUpdate(); + + expect(Api.importList).toHaveBeenCalledWith( + expect.objectContaining({ + file: fileMock, + listId: 'my_list_id', + type: 'keyword', + }) + ); + }); + + it('populates result with the response of Api.importList', async () => { + const fileMock = ('my file' as unknown) as File; + + const { result, waitForNextUpdate } = renderHook(() => useImportList()); + + act(() => { + result.current.start({ + file: fileMock, + http: httpMock, + listId: 'my_list_id', + type: 'keyword', + }); + }); + await waitForNextUpdate(); + + expect(result.current.result).toEqual(getListResponseMock()); + }); + + it('error is populated if importList rejects', async () => { + const fileMock = ('my file' as unknown) as File; + (Api.importList as jest.Mock).mockRejectedValue(new Error('whoops')); + const { result, waitForNextUpdate } = renderHook(() => useImportList()); + + act(() => { + result.current.start({ + file: fileMock, + http: httpMock, + listId: 'my_list_id', + type: 'keyword', + }); + }); + + await waitForNextUpdate(); + + expect(result.current.result).toBeUndefined(); + expect(result.current.error).toEqual(new Error('whoops')); + }); +}); diff --git a/x-pack/plugins/lists/public/lists/hooks/use_import_list.ts b/x-pack/plugins/lists/public/lists/hooks/use_import_list.ts new file mode 100644 index 00000000000000..2854acd6e522ee --- /dev/null +++ b/x-pack/plugins/lists/public/lists/hooks/use_import_list.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useAsyncTask } from '../../common/hooks/use_async_task'; +import { ImportListParams } from '../types'; +import { importList } from '../api'; + +export type ImportListTaskArgs = Omit; + +const importListTask = ( + { signal }: AbortController, + args: ImportListTaskArgs +): ReturnType => importList({ signal, ...args }); + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const useImportList = () => useAsyncTask(importListTask); diff --git a/x-pack/plugins/lists/public/lists/types.ts b/x-pack/plugins/lists/public/lists/types.ts new file mode 100644 index 00000000000000..6421ad174d4d96 --- /dev/null +++ b/x-pack/plugins/lists/public/lists/types.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpStart } from '../../../../../src/core/public'; +import { Type } from '../../common/schemas'; + +export interface ApiParams { + http: HttpStart; + signal: AbortSignal; +} +export type ApiPayload = Omit; + +export interface FindListsParams extends ApiParams { + pageSize: number | undefined; + pageIndex: number | undefined; +} + +export interface ImportListParams extends ApiParams { + file: File; + listId: string | undefined; + type: Type | undefined; +} + +export interface DeleteListParams extends ApiParams { + id: string; +} + +export interface ExportListParams extends ApiParams { + listId: string; +} diff --git a/x-pack/plugins/lists/server/routes/export_list_item_route.ts b/x-pack/plugins/lists/server/routes/export_list_item_route.ts index 32b99bfc512bf2..8b50f4666085ae 100644 --- a/x-pack/plugins/lists/server/routes/export_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/export_list_item_route.ts @@ -47,7 +47,7 @@ export const exportListItemRoute = (router: IRouter): void => { body: stream, headers: { 'Content-Disposition': `attachment; filename="${fileName}"`, - 'Content-Type': 'text/plain', + 'Content-Type': 'application/ndjson', }, }); } diff --git a/x-pack/plugins/security_solution/common/validate.test.ts b/x-pack/plugins/security_solution/common/validate.test.ts index 032f6d95901685..b2217099fca19a 100644 --- a/x-pack/plugins/security_solution/common/validate.test.ts +++ b/x-pack/plugins/security_solution/common/validate.test.ts @@ -3,15 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ +import { left, right } from 'fp-ts/lib/Either'; import * as t from 'io-ts'; -import { validate } from './validate'; +import { validate, validateEither } from './validate'; describe('validate', () => { test('it should do a validation correctly', () => { @@ -32,3 +28,21 @@ describe('validate', () => { expect(errors).toEqual('Invalid value "some other value" supplied to "a"'); }); }); + +describe('validateEither', () => { + it('returns the ORIGINAL payload as right if valid', () => { + const schema = t.exact(t.type({ a: t.number })); + const payload = { a: 1 }; + const result = validateEither(schema, payload); + + expect(result).toEqual(right(payload)); + }); + + it('returns an error string if invalid', () => { + const schema = t.exact(t.type({ a: t.number })); + const payload = { a: 'some other value' }; + const result = validateEither(schema, payload); + + expect(result).toEqual(left('Invalid value "some other value" supplied to "a"')); + }); +}); diff --git a/x-pack/plugins/security_solution/common/validate.ts b/x-pack/plugins/security_solution/common/validate.ts index db9e286e2ebc22..f36df38c2a90d2 100644 --- a/x-pack/plugins/security_solution/common/validate.ts +++ b/x-pack/plugins/security_solution/common/validate.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { fold } from 'fp-ts/lib/Either'; +import { fold, Either, mapLeft } from 'fp-ts/lib/Either'; import { pipe } from 'fp-ts/lib/pipeable'; import * as t from 'io-ts'; import { exactCheck } from './exact_check'; @@ -23,3 +23,13 @@ export const validate = ( const right = (output: T): [T | null, string | null] => [output, null]; return pipe(checked, fold(left, right)); }; + +export const validateEither = ( + schema: T, + obj: A +): Either => + pipe( + obj, + (a) => schema.validate(a, t.getDefaultContext(schema.asDecoder())), + mapLeft((errors) => formatErrors(errors).join(',')) + );