diff --git a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts index 93e3305078f8d..62793388e34a6 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts @@ -6,10 +6,14 @@ import { TypeOf } from '@kbn/config-schema'; import { + DeleteTrustedAppsRequestSchema, GetTrustedAppsRequestSchema, PostTrustedAppCreateRequestSchema, } from '../schema/trusted_apps'; +/** API request params for deleting Trusted App entry */ +export type DeleteTrustedAppsRequestParams = TypeOf; + /** API request params for retrieving a list of Trusted Apps */ export type GetTrustedAppsListRequest = TypeOf; diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index 40320ed794203..cb4ed9b098fce 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -90,10 +90,7 @@ export const getPolicyDetailPath = (policyId: string, search?: string) => { })}${appendSearch(search)}`; }; -const isDefaultOrMissing = ( - value: number | string | undefined, - defaultValue: number | undefined -) => { +const isDefaultOrMissing = (value: T | undefined, defaultValue: T) => { return value === undefined || value === defaultValue; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts index a3c5911aa3a86..4fb1e1b4575c8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts @@ -5,19 +5,26 @@ */ import { HttpStart } from 'kibana/public'; + import { TRUSTED_APPS_CREATE_API, + TRUSTED_APPS_DELETE_API, TRUSTED_APPS_LIST_API, } from '../../../../../common/endpoint/constants'; + import { + DeleteTrustedAppsRequestParams, GetTrustedListAppsResponse, GetTrustedAppsListRequest, PostTrustedAppCreateRequest, PostTrustedAppCreateResponse, } from '../../../../../common/endpoint/types/trusted_apps'; +import { resolvePathVariables } from './utils'; + export interface TrustedAppsService { getTrustedAppsList(request: GetTrustedAppsListRequest): Promise; + deleteTrustedApp(request: DeleteTrustedAppsRequestParams): Promise; createTrustedApp(request: PostTrustedAppCreateRequest): Promise; } @@ -30,6 +37,10 @@ export class TrustedAppsHttpService implements TrustedAppsService { }); } + async deleteTrustedApp(request: DeleteTrustedAppsRequestParams): Promise { + return this.http.delete(resolvePathVariables(TRUSTED_APPS_DELETE_API, request)); + } + async createTrustedApp(request: PostTrustedAppCreateRequest) { return this.http.post(TRUSTED_APPS_CREATE_API, { body: JSON.stringify(request), diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.test.ts new file mode 100644 index 0000000000000..c937b318e8961 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.test.ts @@ -0,0 +1,44 @@ +/* + * 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 { resolvePathVariables } from './utils'; + +describe('utils', () => { + describe('resolvePathVariables', () => { + it('should resolve defined variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', { var1: 'value1' })).toBe( + '/segment1/value1/segment2' + ); + }); + + it('should not resolve undefined variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', {})).toBe( + '/segment1/{var1}/segment2' + ); + }); + + it('should ignore unused variables', () => { + expect(resolvePathVariables('/segment1/{var1}/segment2', { var2: 'value2' })).toBe( + '/segment1/{var1}/segment2' + ); + }); + + it('should replace multiple variable occurences', () => { + expect(resolvePathVariables('/{var1}/segment1/{var1}', { var1: 'value1' })).toBe( + '/value1/segment1/value1' + ); + }); + + it('should replace multiple variables', () => { + const path = resolvePathVariables('/{var1}/segment1/{var2}', { + var1: 'value1', + var2: 'value2', + }); + + expect(path).toBe('/value1/segment1/value2'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.ts new file mode 100644 index 0000000000000..075d74da018b4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/utils.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export const resolvePathVariables = (path: string, variables: { [K: string]: string | number }) => + Object.keys(variables).reduce((acc, paramName) => { + return acc.replace(new RegExp(`\{${paramName}\}`, 'g'), String(variables[paramName])); + }, path); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.test.ts index 5e00d833981ed..534a4ec14861b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.test.ts @@ -13,6 +13,7 @@ import { isLoadingResourceState, isLoadedResourceState, isFailedResourceState, + isStaleResourceState, getLastLoadedResourceState, getCurrentResourceError, isOutdatedResourceState, @@ -137,6 +138,24 @@ describe('AsyncResourceState', () => { expect(isFailedResourceState(failedResourceStateInitially)).toBe(true); }); }); + + describe('isStaleResourceState()', () => { + it('returns true for UninitialisedResourceState', () => { + expect(isStaleResourceState(uninitialisedResourceState)).toBe(true); + }); + + it('returns false for LoadingResourceState', () => { + expect(isStaleResourceState(loadingResourceStateInitially)).toBe(false); + }); + + it('returns true for LoadedResourceState', () => { + expect(isStaleResourceState(loadedResourceState)).toBe(true); + }); + + it('returns true for FailedResourceState', () => { + expect(isStaleResourceState(failedResourceStateInitially)).toBe(true); + }); + }); }); describe('functions', () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.ts index 4639a50a61865..bb868418e7f0d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/async_resource_state.ts @@ -35,7 +35,7 @@ export interface UninitialisedResourceState { * @param Data - type of the data that is referenced by resource state * @param Error - type of the error that can happen during attempt to update data */ -export interface LoadingResourceState { +export interface LoadingResourceState { type: 'LoadingResourceState'; previousState: StaleResourceState; } @@ -46,7 +46,7 @@ export interface LoadingResourceState { * * @param Data - type of the data that is referenced by resource state */ -export interface LoadedResourceState { +export interface LoadedResourceState { type: 'LoadedResourceState'; data: Data; } @@ -59,7 +59,7 @@ export interface LoadedResourceState { * @param Data - type of the data that is referenced by resource state * @param Error - type of the error that can happen during attempt to update data */ -export interface FailedResourceState { +export interface FailedResourceState { type: 'FailedResourceState'; error: Error; lastLoadedState?: LoadedResourceState; @@ -71,7 +71,7 @@ export interface FailedResourceState { * @param Data - type of the data that is referenced by resource state * @param Error - type of the error that can happen during attempt to update data */ -export type StaleResourceState = +export type StaleResourceState = | UninitialisedResourceState | LoadedResourceState | FailedResourceState; @@ -82,7 +82,7 @@ export type StaleResourceState = * @param Data - type of the data that is referenced by resource state * @param Error - type of the error that can happen during attempt to update data */ -export type AsyncResourceState = +export type AsyncResourceState = | UninitialisedResourceState | LoadingResourceState | LoadedResourceState @@ -106,6 +106,13 @@ export const isFailedResourceState = ( state: Immutable> ): state is Immutable> => state.type === 'FailedResourceState'; +export const isStaleResourceState = ( + state: Immutable> +): state is Immutable> => + isUninitialisedResourceState(state) || + isLoadedResourceState(state) || + isFailedResourceState(state); + // Set of functions to work with AsyncResourceState export const getLastLoadedResourceState = ( diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts index 071557ec1a815..4c38ac0c4239a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { ServerApiError } from '../../../../common/types'; import { NewTrustedApp, TrustedApp } from '../../../../../common/endpoint/types/trusted_apps'; import { AsyncResourceState } from '.'; import { TrustedAppsUrlParams } from '../types'; -import { ServerApiError } from '../../../../common/types'; export interface PaginationInfo { index: number; @@ -18,6 +18,7 @@ export interface TrustedAppsListData { items: TrustedApp[]; totalItemsCount: number; paginationInfo: PaginationInfo; + timestamp: number; } /** Store State when an API request has been sent to create a new trusted app entry */ @@ -42,8 +43,14 @@ export interface TrustedAppsListPageState { listView: { currentListResourceState: AsyncResourceState; currentPaginationInfo: PaginationInfo; + freshDataTimestamp: number; show: TrustedAppsUrlParams['show'] | undefined; }; + deletionDialog: { + entry?: TrustedApp; + confirmed: boolean; + submissionResourceState: AsyncResourceState; + }; createView: | undefined | TrustedAppCreatePending diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts index 3a43ffe58262c..5315087c09655 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts @@ -4,6 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Action } from 'redux'; + +import { TrustedApp } from '../../../../../common/endpoint/types'; import { AsyncResourceState, TrustedAppCreateFailure, @@ -12,12 +15,30 @@ import { TrustedAppsListData, } from '../state'; -export interface TrustedAppsListResourceStateChanged { - type: 'trustedAppsListResourceStateChanged'; +export type TrustedAppsListDataOutdated = Action<'trustedAppsListDataOutdated'>; + +interface ResourceStateChanged extends Action { + payload: { newState: AsyncResourceState }; +} + +export type TrustedAppsListResourceStateChanged = ResourceStateChanged< + 'trustedAppsListResourceStateChanged', + TrustedAppsListData +>; + +export type TrustedAppDeletionSubmissionResourceStateChanged = ResourceStateChanged< + 'trustedAppDeletionSubmissionResourceStateChanged' +>; + +export type TrustedAppDeletionDialogStarted = Action<'trustedAppDeletionDialogStarted'> & { payload: { - newState: AsyncResourceState; + entry: TrustedApp; }; -} +}; + +export type TrustedAppDeletionDialogConfirmed = Action<'trustedAppDeletionDialogConfirmed'>; + +export type TrustedAppDeletionDialogClosed = Action<'trustedAppDeletionDialogClosed'>; export interface UserClickedSaveNewTrustedAppButton { type: 'userClickedSaveNewTrustedAppButton'; @@ -35,7 +56,12 @@ export interface ServerReturnedCreateTrustedAppFailure { } export type TrustedAppsPageAction = + | TrustedAppsListDataOutdated | TrustedAppsListResourceStateChanged + | TrustedAppDeletionSubmissionResourceStateChanged + | TrustedAppDeletionDialogStarted + | TrustedAppDeletionDialogConfirmed + | TrustedAppDeletionDialogClosed | UserClickedSaveNewTrustedAppButton | ServerReturnedCreateTrustedAppSuccess | ServerReturnedCreateTrustedAppFailure; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts index e5f00ee0ccf81..19c2d3a62781f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts @@ -10,8 +10,10 @@ import { createSpyMiddleware } from '../../../../common/store/test_utils'; import { createFailedListViewWithPagination, + createListLoadedResourceState, createLoadedListViewWithPagination, createLoadingListViewWithPagination, + createSampleTrustedApp, createSampleTrustedApps, createServerApiError, createUserChangedUrlAction, @@ -22,6 +24,14 @@ import { PaginationInfo, TrustedAppsListPageState } from '../state'; import { initialTrustedAppsPageState, trustedAppsPageReducer } from './reducer'; import { createTrustedAppsPageMiddleware } from './middleware'; +const initialNow = 111111; +const dateNowMock = jest.fn(); +dateNowMock.mockReturnValue(initialNow); + +Date.now = dateNowMock; + +const initialState = initialTrustedAppsPageState(); + const createGetTrustedListAppsResponse = (pagination: PaginationInfo, totalItemsCount: number) => ({ data: createSampleTrustedApps(pagination), page: pagination.index, @@ -31,6 +41,7 @@ const createGetTrustedListAppsResponse = (pagination: PaginationInfo, totalItems const createTrustedAppsServiceMock = (): jest.Mocked => ({ getTrustedAppsList: jest.fn(), + deleteTrustedApp: jest.fn(), createTrustedApp: jest.fn(), }); @@ -50,13 +61,19 @@ const createStoreSetup = (trustedAppsService: TrustedAppsService) => { }; describe('middleware', () => { - describe('refreshing list resource state', () => { + beforeEach(() => { + dateNowMock.mockReturnValue(initialNow); + }); + + describe('initial state', () => { it('sets initial state properly', async () => { expect(createStoreSetup(createTrustedAppsServiceMock()).store.getState()).toStrictEqual( - initialTrustedAppsPageState + initialState ); }); + }); + describe('refreshing list resource state', () => { it('refreshes the list when location changes and data gets outdated', async () => { const pagination = { index: 2, size: 50 }; const service = createTrustedAppsServiceMock(); @@ -69,17 +86,17 @@ describe('middleware', () => { store.dispatch(createUserChangedUrlAction('/trusted_apps', '?page_index=2&page_size=50')); expect(store.getState()).toStrictEqual({ - listView: createLoadingListViewWithPagination(pagination), + ...initialState, + listView: createLoadingListViewWithPagination(initialNow, pagination), active: true, - createView: undefined, }); await spyMiddleware.waitForAction('trustedAppsListResourceStateChanged'); expect(store.getState()).toStrictEqual({ - listView: createLoadedListViewWithPagination(pagination, pagination, 500), + ...initialState, + listView: createLoadedListViewWithPagination(initialNow, pagination, pagination, 500), active: true, - createView: undefined, }); }); @@ -100,13 +117,50 @@ describe('middleware', () => { expect(service.getTrustedAppsList).toBeCalledTimes(1); expect(store.getState()).toStrictEqual({ - listView: createLoadedListViewWithPagination(pagination, pagination, 500), + ...initialState, + listView: createLoadedListViewWithPagination(initialNow, pagination, pagination, 500), active: true, - createView: undefined, }); }); - it('set list resource state to faile when failing to load data', async () => { + it('refreshes the list when data gets outdated with and outdate action', async () => { + const newNow = 222222; + const pagination = { index: 0, size: 10 }; + const service = createTrustedAppsServiceMock(); + const { store, spyMiddleware } = createStoreSetup(service); + + service.getTrustedAppsList.mockResolvedValue( + createGetTrustedListAppsResponse(pagination, 500) + ); + + store.dispatch(createUserChangedUrlAction('/trusted_apps')); + + await spyMiddleware.waitForAction('trustedAppsListResourceStateChanged'); + + dateNowMock.mockReturnValue(newNow); + + store.dispatch({ type: 'trustedAppsListDataOutdated' }); + + expect(store.getState()).toStrictEqual({ + ...initialState, + listView: createLoadingListViewWithPagination( + newNow, + pagination, + createListLoadedResourceState(pagination, 500, initialNow) + ), + active: true, + }); + + await spyMiddleware.waitForAction('trustedAppsListResourceStateChanged'); + + expect(store.getState()).toStrictEqual({ + ...initialState, + listView: createLoadedListViewWithPagination(newNow, pagination, pagination, 500), + active: true, + }); + }); + + it('set list resource state to failed when failing to load data', async () => { const service = createTrustedAppsServiceMock(); const { store, spyMiddleware } = createStoreSetup(service); @@ -117,12 +171,13 @@ describe('middleware', () => { await spyMiddleware.waitForAction('trustedAppsListResourceStateChanged'); expect(store.getState()).toStrictEqual({ + ...initialState, listView: createFailedListViewWithPagination( + initialNow, { index: 2, size: 50 }, createServerApiError('Internal Server Error') ), active: true, - createView: undefined, }); const infiniteLoopTest = async () => { @@ -132,4 +187,151 @@ describe('middleware', () => { await expect(infiniteLoopTest).rejects.not.toBeNull(); }); }); + + describe('submitting deletion dialog', () => { + const newNow = 222222; + const entry = createSampleTrustedApp(3); + const notFoundError = createServerApiError('Not Found'); + const pagination = { index: 0, size: 10 }; + const getTrustedAppsListResponse = createGetTrustedListAppsResponse(pagination, 500); + const listView = createLoadedListViewWithPagination(initialNow, pagination, pagination, 500); + const listViewNew = createLoadedListViewWithPagination(newNow, pagination, pagination, 500); + const testStartState = { ...initialState, listView, active: true }; + + it('does not submit when entry is undefined', async () => { + const service = createTrustedAppsServiceMock(); + const { store, spyMiddleware } = createStoreSetup(service); + + service.getTrustedAppsList.mockResolvedValue(getTrustedAppsListResponse); + service.deleteTrustedApp.mockResolvedValue(); + + store.dispatch(createUserChangedUrlAction('/trusted_apps')); + + await spyMiddleware.waitForAction('trustedAppsListResourceStateChanged'); + + store.dispatch({ type: 'trustedAppDeletionDialogConfirmed' }); + + expect(store.getState()).toStrictEqual({ + ...testStartState, + deletionDialog: { ...testStartState.deletionDialog, confirmed: true }, + }); + }); + + it('submits successfully when entry is defined', async () => { + const service = createTrustedAppsServiceMock(); + const { store, spyMiddleware } = createStoreSetup(service); + + service.getTrustedAppsList.mockResolvedValue(getTrustedAppsListResponse); + service.deleteTrustedApp.mockResolvedValue(); + + store.dispatch(createUserChangedUrlAction('/trusted_apps')); + + await spyMiddleware.waitForAction('trustedAppsListResourceStateChanged'); + + dateNowMock.mockReturnValue(newNow); + + store.dispatch({ type: 'trustedAppDeletionDialogStarted', payload: { entry } }); + store.dispatch({ type: 'trustedAppDeletionDialogConfirmed' }); + + expect(store.getState()).toStrictEqual({ + ...testStartState, + deletionDialog: { + entry, + confirmed: true, + submissionResourceState: { + type: 'LoadingResourceState', + previousState: { type: 'UninitialisedResourceState' }, + }, + }, + }); + + await spyMiddleware.waitForAction('trustedAppDeletionSubmissionResourceStateChanged'); + await spyMiddleware.waitForAction('trustedAppsListResourceStateChanged'); + + expect(store.getState()).toStrictEqual({ ...testStartState, listView: listViewNew }); + expect(service.deleteTrustedApp).toBeCalledWith({ id: '3' }); + expect(service.deleteTrustedApp).toBeCalledTimes(1); + }); + + it('does not submit twice', async () => { + const service = createTrustedAppsServiceMock(); + const { store, spyMiddleware } = createStoreSetup(service); + + service.getTrustedAppsList.mockResolvedValue(getTrustedAppsListResponse); + service.deleteTrustedApp.mockResolvedValue(); + + store.dispatch(createUserChangedUrlAction('/trusted_apps')); + + await spyMiddleware.waitForAction('trustedAppsListResourceStateChanged'); + + dateNowMock.mockReturnValue(newNow); + + store.dispatch({ type: 'trustedAppDeletionDialogStarted', payload: { entry } }); + store.dispatch({ type: 'trustedAppDeletionDialogConfirmed' }); + store.dispatch({ type: 'trustedAppDeletionDialogConfirmed' }); + + expect(store.getState()).toStrictEqual({ + ...testStartState, + deletionDialog: { + entry, + confirmed: true, + submissionResourceState: { + type: 'LoadingResourceState', + previousState: { type: 'UninitialisedResourceState' }, + }, + }, + }); + + await spyMiddleware.waitForAction('trustedAppDeletionSubmissionResourceStateChanged'); + await spyMiddleware.waitForAction('trustedAppsListResourceStateChanged'); + + expect(store.getState()).toStrictEqual({ ...testStartState, listView: listViewNew }); + expect(service.deleteTrustedApp).toBeCalledWith({ id: '3' }); + expect(service.deleteTrustedApp).toBeCalledTimes(1); + }); + + it('does not submit when server response with failure', async () => { + const service = createTrustedAppsServiceMock(); + const { store, spyMiddleware } = createStoreSetup(service); + + service.getTrustedAppsList.mockResolvedValue(getTrustedAppsListResponse); + service.deleteTrustedApp.mockRejectedValue(notFoundError); + + store.dispatch(createUserChangedUrlAction('/trusted_apps')); + + await spyMiddleware.waitForAction('trustedAppsListResourceStateChanged'); + + store.dispatch({ type: 'trustedAppDeletionDialogStarted', payload: { entry } }); + store.dispatch({ type: 'trustedAppDeletionDialogConfirmed' }); + + expect(store.getState()).toStrictEqual({ + ...testStartState, + deletionDialog: { + entry, + confirmed: true, + submissionResourceState: { + type: 'LoadingResourceState', + previousState: { type: 'UninitialisedResourceState' }, + }, + }, + }); + + await spyMiddleware.waitForAction('trustedAppDeletionSubmissionResourceStateChanged'); + + expect(store.getState()).toStrictEqual({ + ...testStartState, + deletionDialog: { + entry, + confirmed: true, + submissionResourceState: { + type: 'FailedResourceState', + error: notFoundError, + lastLoadedState: undefined, + }, + }, + }); + expect(service.deleteTrustedApp).toBeCalledWith({ id: '3' }); + expect(service.deleteTrustedApp).toBeCalledTimes(1); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts index bf9cacff5caf0..dd96c8d807048 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts @@ -16,15 +16,22 @@ import { TrustedAppsHttpService, TrustedAppsService } from '../service'; import { AsyncResourceState, + getLastLoadedResourceState, + isStaleResourceState, StaleResourceState, TrustedAppsListData, TrustedAppsListPageState, } from '../state'; -import { TrustedAppsListResourceStateChanged } from './action'; +import { + TrustedAppDeletionSubmissionResourceStateChanged, + TrustedAppsListResourceStateChanged, +} from './action'; import { getCurrentListResourceState, + getDeletionDialogEntry, + getDeletionSubmissionResourceState, getLastLoadedListResourceState, getListCurrentPageIndex, getListCurrentPageSize, @@ -40,46 +47,98 @@ const createTrustedAppsListResourceStateChangedAction = ( payload: { newState }, }); -const refreshList = async ( +const refreshListIfNeeded = async ( store: ImmutableMiddlewareAPI, trustedAppsService: TrustedAppsService ) => { - store.dispatch( - createTrustedAppsListResourceStateChangedAction({ - type: 'LoadingResourceState', - // need to think on how to avoid the casting - previousState: getCurrentListResourceState(store.getState()) as Immutable< - StaleResourceState - >, - }) - ); - - try { - const pageIndex = getListCurrentPageIndex(store.getState()); - const pageSize = getListCurrentPageSize(store.getState()); - const response = await trustedAppsService.getTrustedAppsList({ - page: pageIndex + 1, - per_page: pageSize, - }); - + if (needsRefreshOfListData(store.getState())) { store.dispatch( createTrustedAppsListResourceStateChangedAction({ - type: 'LoadedResourceState', - data: { - items: response.data, - totalItemsCount: response.total, - paginationInfo: { index: pageIndex, size: pageSize }, - }, + type: 'LoadingResourceState', + // need to think on how to avoid the casting + previousState: getCurrentListResourceState(store.getState()) as Immutable< + StaleResourceState + >, }) ); - } catch (error) { + + try { + const pageIndex = getListCurrentPageIndex(store.getState()); + const pageSize = getListCurrentPageSize(store.getState()); + const response = await trustedAppsService.getTrustedAppsList({ + page: pageIndex + 1, + per_page: pageSize, + }); + + store.dispatch( + createTrustedAppsListResourceStateChangedAction({ + type: 'LoadedResourceState', + data: { + items: response.data, + totalItemsCount: response.total, + paginationInfo: { index: pageIndex, size: pageSize }, + timestamp: Date.now(), + }, + }) + ); + } catch (error) { + store.dispatch( + createTrustedAppsListResourceStateChangedAction({ + type: 'FailedResourceState', + error, + lastLoadedState: getLastLoadedListResourceState(store.getState()), + }) + ); + } + } +}; + +const createTrustedAppDeletionSubmissionResourceStateChanged = ( + newState: Immutable +): Immutable => ({ + type: 'trustedAppDeletionSubmissionResourceStateChanged', + payload: { newState }, +}); + +const submitDeletionIfNeeded = async ( + store: ImmutableMiddlewareAPI, + trustedAppsService: TrustedAppsService +) => { + const submissionResourceState = getDeletionSubmissionResourceState(store.getState()); + const entry = getDeletionDialogEntry(store.getState()); + + if (isStaleResourceState(submissionResourceState) && entry !== undefined) { store.dispatch( - createTrustedAppsListResourceStateChangedAction({ - type: 'FailedResourceState', - error, - lastLoadedState: getLastLoadedListResourceState(store.getState()), + createTrustedAppDeletionSubmissionResourceStateChanged({ + type: 'LoadingResourceState', + previousState: submissionResourceState, }) ); + + try { + await trustedAppsService.deleteTrustedApp({ id: entry.id }); + + store.dispatch( + createTrustedAppDeletionSubmissionResourceStateChanged({ + type: 'LoadedResourceState', + data: null, + }) + ); + store.dispatch({ + type: 'trustedAppDeletionDialogClosed', + }); + store.dispatch({ + type: 'trustedAppsListDataOutdated', + }); + } catch (error) { + store.dispatch( + createTrustedAppDeletionSubmissionResourceStateChanged({ + type: 'FailedResourceState', + error, + lastLoadedState: getLastLoadedResourceState(submissionResourceState), + }) + ); + } } }; @@ -102,7 +161,9 @@ const createTrustedApp = async ( data: createdTrustedApp, }, }); - refreshList(store, trustedAppsService); + store.dispatch({ + type: 'trustedAppsListDataOutdated', + }); } catch (error) { dispatch({ type: 'serverReturnedCreateTrustedAppFailure', @@ -122,8 +183,12 @@ export const createTrustedAppsPageMiddleware = ( next(action); // TODO: need to think if failed state is a good condition to consider need for refresh - if (action.type === 'userChangedUrl' && needsRefreshOfListData(store.getState())) { - await refreshList(store, trustedAppsService); + if (action.type === 'userChangedUrl' || action.type === 'trustedAppsListDataOutdated') { + await refreshListIfNeeded(store, trustedAppsService); + } + + if (action.type === 'trustedAppDeletionDialogConfirmed') { + await submitDeletionIfNeeded(store, trustedAppsService); } if (action.type === 'userClickedSaveNewTrustedAppButton') { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts index 76dd4b48e63d2..228f0932edd28 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts @@ -4,93 +4,180 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AsyncResourceState } from '../state'; import { initialTrustedAppsPageState, trustedAppsPageReducer } from './reducer'; import { + createSampleTrustedApp, createListLoadedResourceState, createLoadedListViewWithPagination, - createTrustedAppsListResourceStateChangedAction, createUserChangedUrlAction, + createTrustedAppsListResourceStateChangedAction, } from '../test_utils'; +const initialNow = 111111; +const dateNowMock = jest.fn(); +dateNowMock.mockReturnValue(initialNow); + +Date.now = dateNowMock; + +const initialState = initialTrustedAppsPageState(); + describe('reducer', () => { describe('UserChangedUrl', () => { it('makes page state active and extracts pagination parameters', () => { const result = trustedAppsPageReducer( - initialTrustedAppsPageState, + initialState, createUserChangedUrlAction('/trusted_apps', '?page_index=5&page_size=50') ); expect(result).toStrictEqual({ - listView: { - ...initialTrustedAppsPageState.listView, - currentPaginationInfo: { index: 5, size: 50 }, - }, + ...initialState, + listView: { ...initialState.listView, currentPaginationInfo: { index: 5, size: 50 } }, active: true, - createView: undefined, }); }); it('extracts default pagination parameters when none provided', () => { const result = trustedAppsPageReducer( { - ...initialTrustedAppsPageState, - listView: { - ...initialTrustedAppsPageState.listView, - currentPaginationInfo: { index: 5, size: 50 }, - }, + ...initialState, + listView: { ...initialState.listView, currentPaginationInfo: { index: 5, size: 50 } }, }, createUserChangedUrlAction('/trusted_apps', '?page_index=b&page_size=60') ); - expect(result).toStrictEqual({ - ...initialTrustedAppsPageState, - active: true, - }); + expect(result).toStrictEqual({ ...initialState, active: true }); }); it('extracts default pagination parameters when invalid provided', () => { const result = trustedAppsPageReducer( { - ...initialTrustedAppsPageState, - listView: { - ...initialTrustedAppsPageState.listView, - currentPaginationInfo: { index: 5, size: 50 }, - }, + ...initialState, + listView: { ...initialState.listView, currentPaginationInfo: { index: 5, size: 50 } }, }, createUserChangedUrlAction('/trusted_apps') ); - expect(result).toStrictEqual({ - ...initialTrustedAppsPageState, - active: true, - }); + expect(result).toStrictEqual({ ...initialState, active: true }); }); it('makes page state inactive and resets list to uninitialised state when navigating away', () => { const result = trustedAppsPageReducer( - { listView: createLoadedListViewWithPagination(), active: true, createView: undefined }, + { ...initialState, listView: createLoadedListViewWithPagination(initialNow), active: true }, createUserChangedUrlAction('/endpoints') ); - expect(result).toStrictEqual(initialTrustedAppsPageState); + expect(result).toStrictEqual(initialState); }); }); describe('TrustedAppsListResourceStateChanged', () => { it('sets the current list resource state', () => { - const listResourceState = createListLoadedResourceState({ index: 3, size: 50 }, 200); + const listResourceState = createListLoadedResourceState( + { index: 3, size: 50 }, + 200, + initialNow + ); const result = trustedAppsPageReducer( - initialTrustedAppsPageState, + initialState, createTrustedAppsListResourceStateChangedAction(listResourceState) ); expect(result).toStrictEqual({ - ...initialTrustedAppsPageState, - listView: { - ...initialTrustedAppsPageState.listView, - currentListResourceState: listResourceState, + ...initialState, + listView: { ...initialState.listView, currentListResourceState: listResourceState }, + }); + }); + }); + + describe('TrustedAppsListDataOutdated', () => { + it('sets the list view freshness timestamp', () => { + const newNow = 222222; + dateNowMock.mockReturnValue(newNow); + + const result = trustedAppsPageReducer(initialState, { type: 'trustedAppsListDataOutdated' }); + + expect(result).toStrictEqual({ + ...initialState, + listView: { ...initialState.listView, freshDataTimestamp: newNow }, + }); + }); + }); + + describe('TrustedAppDeletionSubmissionResourceStateChanged', () => { + it('sets the deletion dialog submission resource state', () => { + const submissionResourceState: AsyncResourceState = { + type: 'LoadedResourceState', + data: null, + }; + const result = trustedAppsPageReducer(initialState, { + type: 'trustedAppDeletionSubmissionResourceStateChanged', + payload: { newState: submissionResourceState }, + }); + + expect(result).toStrictEqual({ + ...initialState, + deletionDialog: { ...initialState.deletionDialog, submissionResourceState }, + }); + }); + }); + + describe('TrustedAppDeletionDialogStarted', () => { + it('sets the deletion dialog state to started', () => { + const entry = createSampleTrustedApp(3); + const result = trustedAppsPageReducer(initialState, { + type: 'trustedAppDeletionDialogStarted', + payload: { entry }, + }); + + expect(result).toStrictEqual({ + ...initialState, + deletionDialog: { ...initialState.deletionDialog, entry }, + }); + }); + }); + + describe('TrustedAppDeletionDialogConfirmed', () => { + it('sets the deletion dialog state to confirmed', () => { + const entry = createSampleTrustedApp(3); + const result = trustedAppsPageReducer( + { + ...initialState, + deletionDialog: { + entry, + confirmed: false, + submissionResourceState: { type: 'UninitialisedResourceState' }, + }, + }, + { type: 'trustedAppDeletionDialogConfirmed' } + ); + + expect(result).toStrictEqual({ + ...initialState, + deletionDialog: { + entry, + confirmed: true, + submissionResourceState: { type: 'UninitialisedResourceState' }, }, }); }); }); + + describe('TrustedAppDeletionDialogClosed', () => { + it('sets the deletion dialog state to confirmed', () => { + const result = trustedAppsPageReducer( + { + ...initialState, + deletionDialog: { + entry: createSampleTrustedApp(3), + confirmed: true, + submissionResourceState: { type: 'UninitialisedResourceState' }, + }, + }, + { type: 'trustedAppDeletionDialogClosed' } + ); + + expect(result).toStrictEqual(initialState); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts index d824a6e95c8d5..ec210254bf76f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts @@ -19,9 +19,14 @@ import { } from '../../../common/constants'; import { + TrustedAppDeletionDialogClosed, + TrustedAppDeletionDialogConfirmed, + TrustedAppDeletionDialogStarted, + TrustedAppDeletionSubmissionResourceStateChanged, + TrustedAppsListDataOutdated, + TrustedAppsListResourceStateChanged, ServerReturnedCreateTrustedAppFailure, ServerReturnedCreateTrustedAppSuccess, - TrustedAppsListResourceStateChanged, UserClickedSaveNewTrustedAppButton, } from './action'; import { TrustedAppsListPageState } from '../state'; @@ -41,6 +46,16 @@ const isTrustedAppsPageLocation = (location: Immutable) => { ); }; +const trustedAppsListDataOutdated: CaseReducer = (state, action) => { + return { + ...state, + listView: { + ...state.listView, + freshDataTimestamp: Date.now(), + }, + }; +}; + const trustedAppsListResourceStateChanged: CaseReducer = ( state, action @@ -54,6 +69,44 @@ const trustedAppsListResourceStateChanged: CaseReducer = ( + state, + action +) => { + return { + ...state, + deletionDialog: { ...state.deletionDialog, submissionResourceState: action.payload.newState }, + }; +}; + +const trustedAppDeletionDialogStarted: CaseReducer = ( + state, + action +) => { + return { + ...state, + deletionDialog: { + entry: action.payload.entry, + confirmed: false, + submissionResourceState: { type: 'UninitialisedResourceState' }, + }, + }; +}; + +const trustedAppDeletionDialogConfirmed: CaseReducer = ( + state, + action +) => { + return { ...state, deletionDialog: { ...state.deletionDialog, confirmed: true } }; +}; + +const trustedAppDeletionDialogClosed: CaseReducer = ( + state, + action +) => { + return { ...state, deletionDialog: initialDeletionDialogState() }; +}; + const userChangedUrl: CaseReducer = (state, action) => { if (isTrustedAppsPageLocation(action.payload)) { const parsedUrlsParams = parse(action.payload.search.slice(1)); @@ -75,7 +128,7 @@ const userChangedUrl: CaseReducer = (state, action) => { active: true, }; } else { - return initialTrustedAppsPageState; + return initialTrustedAppsPageState(); } }; @@ -90,27 +143,49 @@ const trustedAppsCreateResourceChanged: CaseReducer< }; }; -export const initialTrustedAppsPageState: TrustedAppsListPageState = { +const initialDeletionDialogState = (): TrustedAppsListPageState['deletionDialog'] => ({ + confirmed: false, + submissionResourceState: { type: 'UninitialisedResourceState' }, +}); + +export const initialTrustedAppsPageState = (): TrustedAppsListPageState => ({ listView: { currentListResourceState: { type: 'UninitialisedResourceState' }, currentPaginationInfo: { index: MANAGEMENT_DEFAULT_PAGE, size: MANAGEMENT_DEFAULT_PAGE_SIZE, }, + freshDataTimestamp: Date.now(), show: undefined, }, + deletionDialog: initialDeletionDialogState(), createView: undefined, active: false, -}; +}); export const trustedAppsPageReducer: StateReducer = ( - state = initialTrustedAppsPageState, + state = initialTrustedAppsPageState(), action ) => { switch (action.type) { + case 'trustedAppsListDataOutdated': + return trustedAppsListDataOutdated(state, action); + case 'trustedAppsListResourceStateChanged': return trustedAppsListResourceStateChanged(state, action); + case 'trustedAppDeletionSubmissionResourceStateChanged': + return trustedAppDeletionSubmissionResourceStateChanged(state, action); + + case 'trustedAppDeletionDialogStarted': + return trustedAppDeletionDialogStarted(state, action); + + case 'trustedAppDeletionDialogConfirmed': + return trustedAppDeletionDialogConfirmed(state, action); + + case 'trustedAppDeletionDialogClosed': + return trustedAppDeletionDialogClosed(state, action); + case 'userChangedUrl': return userChangedUrl(state, action); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts index 453afa1befa6b..0be4d0b05acc4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AsyncResourceState, TrustedAppsListPageState } from '../state'; +import { initialTrustedAppsPageState } from './reducer'; import { getCurrentListResourceState, getLastLoadedListResourceState, @@ -14,6 +16,12 @@ import { getListTotalItemsCount, isListLoading, needsRefreshOfListData, + isDeletionDialogOpen, + isDeletionInProgress, + isDeletionSuccessful, + getDeletionError, + getDeletionDialogEntry, + getDeletionSubmissionResourceState, } from './selectors'; import { @@ -23,96 +31,118 @@ import { createListFailedResourceState, createListLoadedResourceState, createLoadedListViewWithPagination, + createSampleTrustedApp, createSampleTrustedApps, + createServerApiError, createUninitialisedResourceState, } from '../test_utils'; +const initialNow = 111111; +const dateNowMock = jest.fn(); +dateNowMock.mockReturnValue(initialNow); + +Date.now = dateNowMock; + +const initialState = initialTrustedAppsPageState(); + +const createStateWithDeletionSubmissionResourceState = ( + submissionResourceState: AsyncResourceState +): TrustedAppsListPageState => ({ + ...initialState, + deletionDialog: { ...initialState.deletionDialog, submissionResourceState }, +}); + describe('selectors', () => { describe('needsRefreshOfListData()', () => { it('returns false for outdated resource state and inactive state', () => { - expect( - needsRefreshOfListData({ - listView: createDefaultListView(), - active: false, - createView: undefined, - }) - ).toBe(false); + expect(needsRefreshOfListData(initialState)).toBe(false); }); it('returns true for outdated resource state and active state', () => { - expect( - needsRefreshOfListData({ - listView: createDefaultListView(), - active: true, - createView: undefined, - }) - ).toBe(true); + expect(needsRefreshOfListData({ ...initialState, active: true })).toBe(true); }); it('returns true when current loaded page index is outdated', () => { - const listView = createLoadedListViewWithPagination({ index: 1, size: 20 }); + const listView = createLoadedListViewWithPagination(initialNow, { index: 1, size: 20 }); - expect(needsRefreshOfListData({ listView, active: true, createView: undefined })).toBe(true); + expect(needsRefreshOfListData({ ...initialState, listView, active: true })).toBe(true); }); it('returns true when current loaded page size is outdated', () => { - const listView = createLoadedListViewWithPagination({ index: 0, size: 50 }); + const listView = createLoadedListViewWithPagination(initialNow, { index: 0, size: 50 }); + + expect(needsRefreshOfListData({ ...initialState, listView, active: true })).toBe(true); + }); + + it('returns true when current loaded data timestamp is outdated', () => { + const listView = { + ...createLoadedListViewWithPagination(111111), + freshDataTimestamp: 222222, + }; - expect(needsRefreshOfListData({ listView, active: true, createView: undefined })).toBe(true); + expect(needsRefreshOfListData({ ...initialState, listView, active: true })).toBe(true); }); it('returns false when current loaded data is up to date', () => { - const listView = createLoadedListViewWithPagination(); + const listView = createLoadedListViewWithPagination(initialNow); - expect(needsRefreshOfListData({ listView, active: true, createView: undefined })).toBe(false); + expect(needsRefreshOfListData({ ...initialState, listView, active: true })).toBe(false); }); }); describe('getCurrentListResourceState()', () => { it('returns current list resource state', () => { - const listView = createDefaultListView(); + const state = { ...initialState, listView: createDefaultListView(initialNow) }; - expect( - getCurrentListResourceState({ listView, active: false, createView: undefined }) - ).toStrictEqual(createUninitialisedResourceState()); + expect(getCurrentListResourceState(state)).toStrictEqual(createUninitialisedResourceState()); }); }); describe('getLastLoadedListResourceState()', () => { it('returns last loaded list resource state', () => { - const listView = { - currentListResourceState: createListComplexLoadingResourceState( - createDefaultPaginationInfo(), - 200 - ), - currentPaginationInfo: createDefaultPaginationInfo(), - show: undefined, + const state = { + ...initialState, + listView: { + currentListResourceState: createListComplexLoadingResourceState( + createDefaultPaginationInfo(), + 200, + initialNow + ), + currentPaginationInfo: createDefaultPaginationInfo(), + freshDataTimestamp: initialNow, + show: undefined, + }, }; - expect( - getLastLoadedListResourceState({ listView, active: false, createView: undefined }) - ).toStrictEqual(createListLoadedResourceState(createDefaultPaginationInfo(), 200)); + expect(getLastLoadedListResourceState(state)).toStrictEqual( + createListLoadedResourceState(createDefaultPaginationInfo(), 200, initialNow) + ); }); }); describe('getListItems()', () => { it('returns empty list when no valid data loaded', () => { - expect( - getListItems({ listView: createDefaultListView(), active: false, createView: undefined }) - ).toStrictEqual([]); + const state = { ...initialState, listView: createDefaultListView(initialNow) }; + + expect(getListItems(state)).toStrictEqual([]); }); it('returns last loaded list items', () => { - const listView = { - currentListResourceState: createListComplexLoadingResourceState( - createDefaultPaginationInfo(), - 200 - ), - currentPaginationInfo: createDefaultPaginationInfo(), - show: undefined, + const state = { + ...initialState, + listView: { + currentListResourceState: createListComplexLoadingResourceState( + createDefaultPaginationInfo(), + 200, + initialNow + ), + currentPaginationInfo: createDefaultPaginationInfo(), + freshDataTimestamp: initialNow, + show: undefined, + }, }; - expect(getListItems({ listView, active: false, createView: undefined })).toStrictEqual( + expect(getListItems(state)).toStrictEqual( createSampleTrustedApps(createDefaultPaginationInfo()) ); }); @@ -120,100 +150,239 @@ describe('selectors', () => { describe('getListTotalItemsCount()', () => { it('returns 0 when no valid data loaded', () => { - expect( - getListTotalItemsCount({ - listView: createDefaultListView(), - active: false, - createView: undefined, - }) - ).toBe(0); + const state = { ...initialState, listView: createDefaultListView(initialNow) }; + + expect(getListTotalItemsCount(state)).toBe(0); }); it('returns last loaded total items count', () => { - const listView = { - currentListResourceState: createListComplexLoadingResourceState( - createDefaultPaginationInfo(), - 200 - ), - currentPaginationInfo: createDefaultPaginationInfo(), - show: undefined, + const state = { + ...initialState, + listView: { + currentListResourceState: createListComplexLoadingResourceState( + createDefaultPaginationInfo(), + 200, + initialNow + ), + currentPaginationInfo: createDefaultPaginationInfo(), + freshDataTimestamp: initialNow, + show: undefined, + }, }; - expect(getListTotalItemsCount({ listView, active: false, createView: undefined })).toBe(200); + expect(getListTotalItemsCount(state)).toBe(200); }); }); describe('getListCurrentPageIndex()', () => { it('returns page index', () => { - expect( - getListCurrentPageIndex({ - listView: createDefaultListView(), - active: false, - createView: undefined, - }) - ).toBe(0); + const state = { ...initialState, listView: createDefaultListView(initialNow) }; + + expect(getListCurrentPageIndex(state)).toBe(0); }); }); describe('getListCurrentPageSize()', () => { - it('returns page index', () => { - expect( - getListCurrentPageSize({ - listView: createDefaultListView(), - active: false, - createView: undefined, - }) - ).toBe(20); + it('returns page size', () => { + const state = { ...initialState, listView: createDefaultListView(initialNow) }; + + expect(getListCurrentPageSize(state)).toBe(20); }); }); describe('getListErrorMessage()', () => { it('returns undefined when not in failed state', () => { - const listView = { - currentListResourceState: createListComplexLoadingResourceState( - createDefaultPaginationInfo(), - 200 - ), - currentPaginationInfo: createDefaultPaginationInfo(), - show: undefined, + const state = { + ...initialState, + listView: { + currentListResourceState: createListComplexLoadingResourceState( + createDefaultPaginationInfo(), + 200, + initialNow + ), + currentPaginationInfo: createDefaultPaginationInfo(), + freshDataTimestamp: initialNow, + show: undefined, + }, }; - expect( - getListErrorMessage({ listView, active: false, createView: undefined }) - ).toBeUndefined(); + expect(getListErrorMessage(state)).toBeUndefined(); }); it('returns message when not in failed state', () => { - const listView = { - currentListResourceState: createListFailedResourceState('Internal Server Error'), - currentPaginationInfo: createDefaultPaginationInfo(), - show: undefined, + const state = { + ...initialState, + listView: { + currentListResourceState: createListFailedResourceState('Internal Server Error'), + currentPaginationInfo: createDefaultPaginationInfo(), + freshDataTimestamp: initialNow, + show: undefined, + }, }; - expect(getListErrorMessage({ listView, active: false, createView: undefined })).toBe( - 'Internal Server Error' - ); + expect(getListErrorMessage(state)).toBe('Internal Server Error'); }); }); describe('isListLoading()', () => { it('returns false when no loading is happening', () => { - expect( - isListLoading({ listView: createDefaultListView(), active: false, createView: undefined }) - ).toBe(false); + expect(isListLoading(initialState)).toBe(false); }); it('returns true when loading is in progress', () => { - const listView = { - currentListResourceState: createListComplexLoadingResourceState( - createDefaultPaginationInfo(), - 200 - ), - currentPaginationInfo: createDefaultPaginationInfo(), - show: undefined, + const state = { + ...initialState, + listView: { + currentListResourceState: createListComplexLoadingResourceState( + createDefaultPaginationInfo(), + 200, + initialNow + ), + currentPaginationInfo: createDefaultPaginationInfo(), + freshDataTimestamp: initialNow, + show: undefined, + }, }; - expect(isListLoading({ listView, active: false, createView: undefined })).toBe(true); + expect(isListLoading(state)).toBe(true); + }); + }); + + describe('isDeletionDialogOpen()', () => { + it('returns false when no entry is set', () => { + expect(isDeletionDialogOpen(initialState)).toBe(false); + }); + + it('returns true when entry is set', () => { + const state = { + ...initialState, + deletionDialog: { + ...initialState.deletionDialog, + entry: createSampleTrustedApp(5), + }, + }; + + expect(isDeletionDialogOpen(state)).toBe(true); + }); + }); + + describe('isDeletionInProgress()', () => { + it('returns false when resource state is uninitialised', () => { + expect(isDeletionInProgress(initialState)).toBe(false); + }); + + it('returns true when resource state is loading', () => { + const state = createStateWithDeletionSubmissionResourceState({ + type: 'LoadingResourceState', + previousState: { type: 'UninitialisedResourceState' }, + }); + + expect(isDeletionInProgress(state)).toBe(true); + }); + + it('returns false when resource state is loaded', () => { + const state = createStateWithDeletionSubmissionResourceState({ + type: 'LoadedResourceState', + data: null, + }); + + expect(isDeletionInProgress(state)).toBe(false); + }); + + it('returns false when resource state is failed', () => { + const state = createStateWithDeletionSubmissionResourceState({ + type: 'FailedResourceState', + error: createServerApiError('Not Found'), + }); + + expect(isDeletionInProgress(state)).toBe(false); + }); + }); + + describe('isDeletionSuccessful()', () => { + it('returns false when resource state is uninitialised', () => { + expect(isDeletionSuccessful(initialState)).toBe(false); + }); + + it('returns false when resource state is loading', () => { + const state = createStateWithDeletionSubmissionResourceState({ + type: 'LoadingResourceState', + previousState: { type: 'UninitialisedResourceState' }, + }); + + expect(isDeletionSuccessful(state)).toBe(false); + }); + + it('returns true when resource state is loaded', () => { + const state = createStateWithDeletionSubmissionResourceState({ + type: 'LoadedResourceState', + data: null, + }); + + expect(isDeletionSuccessful(state)).toBe(true); + }); + + it('returns false when resource state is failed', () => { + const state = createStateWithDeletionSubmissionResourceState({ + type: 'FailedResourceState', + error: createServerApiError('Not Found'), + }); + + expect(isDeletionSuccessful(state)).toBe(false); + }); + }); + + describe('getDeletionError()', () => { + it('returns undefined when resource state is uninitialised', () => { + expect(getDeletionError(initialState)).toBeUndefined(); + }); + + it('returns undefined when resource state is loading', () => { + const state = createStateWithDeletionSubmissionResourceState({ + type: 'LoadingResourceState', + previousState: { type: 'UninitialisedResourceState' }, + }); + + expect(getDeletionError(state)).toBeUndefined(); + }); + + it('returns undefined when resource state is loaded', () => { + const state = createStateWithDeletionSubmissionResourceState({ + type: 'LoadedResourceState', + data: null, + }); + + expect(getDeletionError(state)).toBeUndefined(); + }); + + it('returns error when resource state is failed', () => { + const state = createStateWithDeletionSubmissionResourceState({ + type: 'FailedResourceState', + error: createServerApiError('Not Found'), + }); + + expect(getDeletionError(state)).toStrictEqual(createServerApiError('Not Found')); + }); + }); + + describe('getDeletionSubmissionResourceState()', () => { + it('returns submission resource state', () => { + expect(getDeletionSubmissionResourceState(initialState)).toStrictEqual({ + type: 'UninitialisedResourceState', + }); + }); + }); + + describe('getDeletionDialogEntry()', () => { + it('returns undefined when no entry is set', () => { + expect(getDeletionDialogEntry(initialState)).toBeUndefined(); + }); + + it('returns entry when entry is set', () => { + const entry = createSampleTrustedApp(5); + const state = { ...initialState, deletionDialog: { ...initialState.deletionDialog, entry } }; + + expect(getDeletionDialogEntry(state)).toStrictEqual(entry); }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts index f074b21f79f4e..6239b425efe2f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts @@ -5,12 +5,15 @@ */ import { createSelector } from 'reselect'; +import { ServerApiError } from '../../../../common/types'; import { Immutable, NewTrustedApp, TrustedApp } from '../../../../../common/endpoint/types'; import { AsyncResourceState, getCurrentResourceError, getLastLoadedResourceState, + isFailedResourceState, + isLoadedResourceState, isLoadingResourceState, isOutdatedResourceState, LoadedResourceState, @@ -32,12 +35,15 @@ const pageInfosEqual = (pageInfo1: PaginationInfo, pageInfo2: PaginationInfo): b export const needsRefreshOfListData = (state: Immutable): boolean => { const currentPageInfo = state.listView.currentPaginationInfo; const currentPage = state.listView.currentListResourceState; + const freshDataTimestamp = state.listView.freshDataTimestamp; return ( state.active && - isOutdatedResourceState(currentPage, (data) => - pageInfosEqual(currentPageInfo, data.paginationInfo) - ) + isOutdatedResourceState(currentPage, (data) => { + return ( + pageInfosEqual(currentPageInfo, data.paginationInfo) && data.timestamp >= freshDataTimestamp + ); + }) ); }; @@ -104,6 +110,38 @@ export const isListLoading = (state: Immutable): boole return isLoadingResourceState(state.listView.currentListResourceState); }; +export const isDeletionDialogOpen = (state: Immutable): boolean => { + return state.deletionDialog.entry !== undefined; +}; + +export const isDeletionInProgress = (state: Immutable): boolean => { + return isLoadingResourceState(state.deletionDialog.submissionResourceState); +}; + +export const isDeletionSuccessful = (state: Immutable): boolean => { + return isLoadedResourceState(state.deletionDialog.submissionResourceState); +}; + +export const getDeletionError = ( + state: Immutable +): Immutable | undefined => { + const submissionResourceState = state.deletionDialog.submissionResourceState; + + return isFailedResourceState(submissionResourceState) ? submissionResourceState.error : undefined; +}; + +export const getDeletionSubmissionResourceState = ( + state: Immutable +): AsyncResourceState => { + return state.deletionDialog.submissionResourceState; +}; + +export const getDeletionDialogEntry = ( + state: Immutable +): Immutable | undefined => { + return state.deletionDialog.entry; +}; + export const isCreatePending: (state: Immutable) => boolean = ({ createView, }) => { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts index 70e4e1e685b01..020a87f526e52 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts @@ -4,10 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +import { combineReducers, createStore } from 'redux'; import { ServerApiError } from '../../../../common/types'; import { TrustedApp } from '../../../../../common/endpoint/types'; import { RoutingAction } from '../../../../common/store/routing'; +import { + MANAGEMENT_STORE_GLOBAL_NAMESPACE, + MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE, +} from '../../../common/constants'; + import { AsyncResourceState, FailedResourceState, @@ -20,30 +26,36 @@ import { UninitialisedResourceState, } from '../state'; +import { trustedAppsPageReducer } from '../store/reducer'; import { TrustedAppsListResourceStateChanged } from '../store/action'; -import { initialTrustedAppsPageState } from '../store/reducer'; const OS_LIST: Array = ['windows', 'macos', 'linux']; -export const createSampleTrustedApps = (paginationInfo: PaginationInfo): TrustedApp[] => { - return [...new Array(paginationInfo.size).keys()].map((i) => ({ - id: String(paginationInfo.index + i), - name: `trusted app ${paginationInfo.index + i}`, - description: `Trusted App ${paginationInfo.index + i}`, +export const createSampleTrustedApp = (i: number): TrustedApp => { + return { + id: String(i), + name: `trusted app ${i}`, + description: `Trusted App ${i}`, created_at: '1 minute ago', created_by: 'someone', os: OS_LIST[i % 3], entries: [], - })); + }; +}; + +export const createSampleTrustedApps = (paginationInfo: PaginationInfo): TrustedApp[] => { + return [...new Array(paginationInfo.size).keys()].map(createSampleTrustedApp); }; export const createTrustedAppsListData = ( paginationInfo: PaginationInfo, - totalItemsCount: number + totalItemsCount: number, + timestamp: number ) => ({ items: createSampleTrustedApps(paginationInfo), totalItemsCount, paginationInfo, + timestamp, }); export const createServerApiError = (message: string) => ({ @@ -58,10 +70,11 @@ export const createUninitialisedResourceState = (): UninitialisedResourceState = export const createListLoadedResourceState = ( paginationInfo: PaginationInfo, - totalItemsCount: number + totalItemsCount: number, + timestamp: number ): LoadedResourceState => ({ type: 'LoadedResourceState', - data: createTrustedAppsListData(paginationInfo, totalItemsCount), + data: createTrustedAppsListData(paginationInfo, totalItemsCount, timestamp), }); export const createListFailedResourceState = ( @@ -82,50 +95,64 @@ export const createListLoadingResourceState = ( export const createListComplexLoadingResourceState = ( paginationInfo: PaginationInfo, - totalItemsCount: number + totalItemsCount: number, + timestamp: number ): LoadingResourceState => createListLoadingResourceState( createListFailedResourceState( 'Internal Server Error', - createListLoadedResourceState(paginationInfo, totalItemsCount) + createListLoadedResourceState(paginationInfo, totalItemsCount, timestamp) ) ); export const createDefaultPaginationInfo = () => ({ index: 0, size: 20 }); -export const createDefaultListView = () => ({ - ...initialTrustedAppsPageState.listView, +export const createDefaultListView = ( + freshDataTimestamp: number +): TrustedAppsListPageState['listView'] => ({ currentListResourceState: createUninitialisedResourceState(), currentPaginationInfo: createDefaultPaginationInfo(), + freshDataTimestamp, + show: undefined, }); export const createLoadingListViewWithPagination = ( + freshDataTimestamp: number, currentPaginationInfo: PaginationInfo, previousState: StaleResourceState = createUninitialisedResourceState() ): TrustedAppsListPageState['listView'] => ({ - ...initialTrustedAppsPageState.listView, currentListResourceState: { type: 'LoadingResourceState', previousState }, currentPaginationInfo, + freshDataTimestamp, + show: undefined, }); export const createLoadedListViewWithPagination = ( + freshDataTimestamp: number, paginationInfo: PaginationInfo = createDefaultPaginationInfo(), currentPaginationInfo: PaginationInfo = createDefaultPaginationInfo(), totalItemsCount: number = 200 ): TrustedAppsListPageState['listView'] => ({ - ...initialTrustedAppsPageState.listView, - currentListResourceState: createListLoadedResourceState(paginationInfo, totalItemsCount), + currentListResourceState: createListLoadedResourceState( + paginationInfo, + totalItemsCount, + freshDataTimestamp + ), currentPaginationInfo, + freshDataTimestamp, + show: undefined, }); export const createFailedListViewWithPagination = ( + freshDataTimestamp: number, currentPaginationInfo: PaginationInfo, error: ServerApiError, lastLoadedState?: LoadedResourceState ): TrustedAppsListPageState['listView'] => ({ - ...initialTrustedAppsPageState.listView, currentListResourceState: { type: 'FailedResourceState', error, lastLoadedState }, currentPaginationInfo, + freshDataTimestamp, + show: undefined, }); export const createUserChangedUrlAction = (path: string, search: string = ''): RoutingAction => { @@ -138,3 +165,13 @@ export const createTrustedAppsListResourceStateChangedAction = ( type: 'trustedAppsListResourceStateChanged', payload: { newState }, }); + +export const createGlobalNoMiddlewareStore = () => { + return createStore( + combineReducers({ + [MANAGEMENT_STORE_GLOBAL_NAMESPACE]: combineReducers({ + [MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE]: trustedAppsPageReducer, + }), + }) + ); +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_app_deletion_dialog.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_app_deletion_dialog.test.tsx.snap new file mode 100644 index 0000000000000..fdb20f229f144 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_app_deletion_dialog.test.tsx.snap @@ -0,0 +1,315 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TrustedAppDeletionDialog renders correctly initially 1`] = ` + +
+ +`; + +exports[`TrustedAppDeletionDialog renders correctly when deletion failed 1`] = ` + +
+
+
+
+ +
+
+
+ Remove trusted application +
+
+
+
+
+

+ You are removing trusted application " + + trusted app 3 + + ". +

+

+ This action cannot be undone. Are you sure you wish to continue? +

+
+
+
+
+ + +
+
+
+
+
+ +`; + +exports[`TrustedAppDeletionDialog renders correctly when deletion is in progress 1`] = ` + +
+
+
+
+ +
+
+
+ Remove trusted application +
+
+
+
+
+

+ You are removing trusted application " + + trusted app 3 + + ". +

+

+ This action cannot be undone. Are you sure you wish to continue? +

+
+
+
+
+ + +
+
+
+
+
+ +`; + +exports[`TrustedAppDeletionDialog renders correctly when dialog started 1`] = ` + +
+
+
+
+ +
+
+
+ Remove trusted application +
+
+
+
+
+

+ You are removing trusted application " + + trusted app 3 + + ". +

+

+ This action cannot be undone. Are you sure you wish to continue? +

+
+
+
+
+ + +
+
+
+
+
+ +`; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_list.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_list.test.tsx.snap index e0f846f5950f7..46885bd653dc2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_list.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_list.test.tsx.snap @@ -98,6 +98,22 @@ exports[`TrustedAppsList renders correctly initially 1`] = `
+ +
+ + Actions + +
+ @@ -106,7 +122,7 @@ exports[`TrustedAppsList renders correctly initially 1`] = ` >
+ +
+ + Actions + +
+ @@ -232,7 +264,7 @@ exports[`TrustedAppsList renders correctly when failed loading data for the firs >
+ +
+ + Actions + +
+ @@ -363,7 +411,7 @@ exports[`TrustedAppsList renders correctly when failed loading data for the seco >
+ +
+ + Actions + +
+
+ +
+ + + + Delete + + +
+
+ +
+ + + + Delete + + +
+
+ +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ - -
- Name + + + + Delete + + +
+ + + + +
+ Name
+ +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ @@ -2174,6 +2838,22 @@ exports[`TrustedAppsList renders correctly when loading data for the first time + +
+ + Actions + +
+ @@ -2182,7 +2862,7 @@ exports[`TrustedAppsList renders correctly when loading data for the first time >
+ +
+ + Actions + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ - + +
+ + + + Delete + + +
+ + + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ @@ -3890,7 +5186,7 @@ exports[`TrustedAppsList renders correctly when loading data for the second time `; -exports[`TrustedAppsList renders correctly when new page and page sie set (not loading yet) 1`] = ` +exports[`TrustedAppsList renders correctly when new page and page size set (not loading yet) 1`] = `
+ +
+ + Actions + +
+
+ +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ + +
+ + + + Delete + + +
+ diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap index c8d9b46d5a0d2..9e0a6b16f8b8a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap @@ -263,6 +263,22 @@ Object { + +
+ + Actions + +
+ @@ -271,7 +287,7 @@ Object { >
+ +
+ + Actions + +
+ @@ -496,7 +528,7 @@ Object { >
) => { + const Wrapper: React.FC = ({ children }) => ( + + {children} + + ); + + return render(, { wrapper: Wrapper }); +}; + +const createDialogStartAction = (): TrustedAppDeletionDialogStarted => ({ + type: 'trustedAppDeletionDialogStarted', + payload: { entry: createSampleTrustedApp(3) }, +}); + +const createDialogLoadingAction = (): TrustedAppDeletionSubmissionResourceStateChanged => ({ + type: 'trustedAppDeletionSubmissionResourceStateChanged', + payload: { + newState: { + type: 'LoadingResourceState', + previousState: { type: 'UninitialisedResourceState' }, + }, + }, +}); + +const createDialogFailedAction = (): TrustedAppDeletionSubmissionResourceStateChanged => ({ + type: 'trustedAppDeletionSubmissionResourceStateChanged', + payload: { + newState: { type: 'FailedResourceState', error: createServerApiError('Not Found') }, + }, +}); + +describe('TrustedAppDeletionDialog', () => { + it('renders correctly initially', () => { + expect(renderDeletionDialog(createGlobalNoMiddlewareStore()).baseElement).toMatchSnapshot(); + }); + + it('renders correctly when dialog started', () => { + const store = createGlobalNoMiddlewareStore(); + + store.dispatch(createDialogStartAction()); + + expect(renderDeletionDialog(store).baseElement).toMatchSnapshot(); + }); + + it('renders correctly when deletion is in progress', () => { + const store = createGlobalNoMiddlewareStore(); + + store.dispatch(createDialogStartAction()); + store.dispatch(createDialogLoadingAction()); + + expect(renderDeletionDialog(store).baseElement).toMatchSnapshot(); + }); + + it('renders correctly when deletion failed', () => { + const store = createGlobalNoMiddlewareStore(); + + store.dispatch(createDialogStartAction()); + store.dispatch(createDialogFailedAction()); + + expect(renderDeletionDialog(store).baseElement).toMatchSnapshot(); + }); + + it('triggers confirmation action when confirm button clicked', async () => { + const store = createGlobalNoMiddlewareStore(); + + store.dispatch(createDialogStartAction()); + store.dispatch = jest.fn(); + + (await renderDeletionDialog(store).findByTestId('trustedAppDeletionConfirm')).click(); + + expect(store.dispatch).toBeCalledWith({ + type: 'trustedAppDeletionDialogConfirmed', + }); + }); + + it('triggers closing action when cancel button clicked', async () => { + const store = createGlobalNoMiddlewareStore(); + + store.dispatch(createDialogStartAction()); + store.dispatch = jest.fn(); + + (await renderDeletionDialog(store).findByTestId('trustedAppDeletionCancel')).click(); + + expect(store.dispatch).toBeCalledWith({ + type: 'trustedAppDeletionDialogClosed', + }); + }); + + it('does not trigger closing action when deletion in progress and cancel button clicked', async () => { + const store = createGlobalNoMiddlewareStore(); + + store.dispatch(createDialogStartAction()); + store.dispatch(createDialogLoadingAction()); + + store.dispatch = jest.fn(); + + (await renderDeletionDialog(store).findByTestId('trustedAppDeletionCancel')).click(); + + expect(store.dispatch).not.toBeCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_app_deletion_dialog.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_app_deletion_dialog.tsx new file mode 100644 index 0000000000000..846fa794ceefd --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_app_deletion_dialog.tsx @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo, useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { Dispatch } from 'redux'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiButtonEmpty, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiText, +} from '@elastic/eui'; + +import { Immutable, TrustedApp } from '../../../../../common/endpoint/types'; +import { AppAction } from '../../../../common/store/actions'; +import { useTrustedAppsSelector } from './hooks'; +import { + getDeletionDialogEntry, + isDeletionDialogOpen, + isDeletionInProgress, +} from '../store/selectors'; + +const CANCEL_SUBJ = 'trustedAppDeletionCancel'; +const CONFIRM_SUBJ = 'trustedAppDeletionConfirm'; + +const getTranslations = (entry: Immutable | undefined) => ({ + title: ( + + ), + mainMessage: ( + {entry?.name} }} + /> + ), + subMessage: ( + + ), + cancelButton: ( + + ), + confirmButton: ( + + ), +}); + +export const TrustedAppDeletionDialog = memo(() => { + const dispatch = useDispatch>(); + const isBusy = useTrustedAppsSelector(isDeletionInProgress); + const entry = useTrustedAppsSelector(getDeletionDialogEntry); + const translations = useMemo(() => getTranslations(entry), [entry]); + const onConfirm = useCallback(() => { + dispatch({ type: 'trustedAppDeletionDialogConfirmed' }); + }, [dispatch]); + const onCancel = useCallback(() => { + if (!isBusy) { + dispatch({ type: 'trustedAppDeletionDialogClosed' }); + } + }, [dispatch, isBusy]); + + if (useTrustedAppsSelector(isDeletionDialogOpen)) { + return ( + + + + {translations.title} + + + + +

{translations.mainMessage}

+

{translations.subMessage}

+
+
+ + + + {translations.cancelButton} + + + + {translations.confirmButton} + + +
+
+ ); + } else { + return <>; + } +}); + +TrustedAppDeletionDialog.displayName = 'TrustedAppDeletionDialog'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.test.tsx index 0362f5c7a9de6..a457ecd0d088f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.test.tsx @@ -3,40 +3,28 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { combineReducers, createStore } from 'redux'; import { render } from '@testing-library/react'; import React from 'react'; import { Provider } from 'react-redux'; -import { - MANAGEMENT_STORE_GLOBAL_NAMESPACE, - MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE, -} from '../../../common/constants'; -import { trustedAppsPageReducer } from '../store/reducer'; import { TrustedAppsList } from './trusted_apps_list'; import { + createSampleTrustedApp, createListFailedResourceState, createListLoadedResourceState, createListLoadingResourceState, createTrustedAppsListResourceStateChangedAction, createUserChangedUrlAction, + createGlobalNoMiddlewareStore, } from '../test_utils'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ htmlIdGenerator: () => () => 'mockId', })); -const createStoreSetup = () => { - return createStore( - combineReducers({ - [MANAGEMENT_STORE_GLOBAL_NAMESPACE]: combineReducers({ - [MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE]: trustedAppsPageReducer, - }), - }) - ); -}; +const now = 111111; -const renderList = (store: ReturnType) => { +const renderList = (store: ReturnType) => { const Wrapper: React.FC = ({ children }) => {children}; return render(, { wrapper: Wrapper }); @@ -44,11 +32,11 @@ const renderList = (store: ReturnType) => { describe('TrustedAppsList', () => { it('renders correctly initially', () => { - expect(renderList(createStoreSetup()).container).toMatchSnapshot(); + expect(renderList(createGlobalNoMiddlewareStore()).container).toMatchSnapshot(); }); it('renders correctly when loading data for the first time', () => { - const store = createStoreSetup(); + const store = createGlobalNoMiddlewareStore(); store.dispatch( createTrustedAppsListResourceStateChangedAction(createListLoadingResourceState()) @@ -58,7 +46,7 @@ describe('TrustedAppsList', () => { }); it('renders correctly when failed loading data for the first time', () => { - const store = createStoreSetup(); + const store = createGlobalNoMiddlewareStore(); store.dispatch( createTrustedAppsListResourceStateChangedAction( @@ -70,23 +58,23 @@ describe('TrustedAppsList', () => { }); it('renders correctly when loaded data', () => { - const store = createStoreSetup(); + const store = createGlobalNoMiddlewareStore(); store.dispatch( createTrustedAppsListResourceStateChangedAction( - createListLoadedResourceState({ index: 0, size: 20 }, 200) + createListLoadedResourceState({ index: 0, size: 20 }, 200, now) ) ); expect(renderList(store).container).toMatchSnapshot(); }); - it('renders correctly when new page and page sie set (not loading yet)', () => { - const store = createStoreSetup(); + it('renders correctly when new page and page size set (not loading yet)', () => { + const store = createGlobalNoMiddlewareStore(); store.dispatch( createTrustedAppsListResourceStateChangedAction( - createListLoadedResourceState({ index: 0, size: 20 }, 200) + createListLoadedResourceState({ index: 0, size: 20 }, 200, now) ) ); store.dispatch(createUserChangedUrlAction('/trusted_apps', '?page_index=2&page_size=50')); @@ -95,11 +83,13 @@ describe('TrustedAppsList', () => { }); it('renders correctly when loading data for the second time', () => { - const store = createStoreSetup(); + const store = createGlobalNoMiddlewareStore(); store.dispatch( createTrustedAppsListResourceStateChangedAction( - createListLoadingResourceState(createListLoadedResourceState({ index: 0, size: 20 }, 200)) + createListLoadingResourceState( + createListLoadedResourceState({ index: 0, size: 20 }, 200, now) + ) ) ); @@ -107,17 +97,37 @@ describe('TrustedAppsList', () => { }); it('renders correctly when failed loading data for the second time', () => { - const store = createStoreSetup(); + const store = createGlobalNoMiddlewareStore(); store.dispatch( createTrustedAppsListResourceStateChangedAction( createListFailedResourceState( 'Intenal Server Error', - createListLoadedResourceState({ index: 0, size: 20 }, 200) + createListLoadedResourceState({ index: 0, size: 20 }, 200, now) ) ) ); expect(renderList(store).container).toMatchSnapshot(); }); + + it('triggers deletion dialog when delete action clicked', async () => { + const store = createGlobalNoMiddlewareStore(); + + store.dispatch( + createTrustedAppsListResourceStateChangedAction( + createListLoadedResourceState({ index: 0, size: 20 }, 200, now) + ) + ); + store.dispatch = jest.fn(); + + (await renderList(store).findAllByTestId('trustedAppDeleteAction'))[0].click(); + + expect(store.dispatch).toBeCalledWith({ + type: 'trustedAppDeletionDialogStarted', + payload: { + entry: createSampleTrustedApp(0), + }, + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.tsx index ea834060d5223..c91512d477510 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_list.tsx @@ -4,12 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Dispatch } from 'redux'; import React, { memo, useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; import { useHistory } from 'react-router-dom'; -import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; +import { EuiBasicTable, EuiBasicTableColumn, EuiTableActionsColumnType } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Immutable } from '../../../../../common/endpoint/types'; +import { AppAction } from '../../../../common/store/actions'; import { TrustedApp } from '../../../../../common/endpoint/types/trusted_apps'; import { MANAGEMENT_PAGE_SIZE_OPTIONS } from '../../../common/constants'; import { getTrustedAppsListPath } from '../../../common/routing'; @@ -28,7 +31,9 @@ import { useTrustedAppsSelector } from './hooks'; import { FormattedDate } from '../../../../common/components/formatted_date'; import { OS_TITLES } from './constants'; -const COLUMN_TITLES: Readonly<{ [K in keyof Omit]: string }> = { +const COLUMN_TITLES: Readonly< + { [K in keyof Omit | 'actions']: string } +> = { name: i18n.translate('xpack.securitySolution.trustedapps.list.columns.name', { defaultMessage: 'Name', }), @@ -41,9 +46,41 @@ const COLUMN_TITLES: Readonly<{ [K in keyof Omit]: created_by: i18n.translate('xpack.securitySolution.trustedapps.list.columns.createdBy', { defaultMessage: 'Created By', }), + actions: i18n.translate('xpack.securitySolution.trustedapps.list.columns.actions', { + defaultMessage: 'Actions', + }), }; -const getColumnDefinitions = (): Array>> => [ +type ActionsList = EuiTableActionsColumnType>['actions']; + +const getActionDefinitions = (dispatch: Dispatch>): ActionsList => [ + { + name: i18n.translate('xpack.securitySolution.trustedapps.list.actions.delete', { + defaultMessage: 'Delete', + }), + description: i18n.translate( + 'xpack.securitySolution.trustedapps.list.actions.delete.description', + { + defaultMessage: 'Delete this entry', + } + ), + 'data-test-subj': 'trustedAppDeleteAction', + isPrimary: true, + icon: 'trash', + color: 'danger', + type: 'icon', + onClick: (item: Immutable) => { + dispatch({ + type: 'trustedAppDeletionDialogStarted', + payload: { entry: item }, + }); + }, + }, +]; + +type ColumnsList = Array>>; + +const getColumnDefinitions = (dispatch: Dispatch>): ColumnsList => [ { field: 'name', name: COLUMN_TITLES.name, @@ -72,6 +109,10 @@ const getColumnDefinitions = (): Array field: 'created_by', name: COLUMN_TITLES.created_by, }, + { + name: COLUMN_TITLES.actions, + actions: getActionDefinitions(dispatch), + }, ]; export const TrustedAppsList = memo(() => { @@ -79,11 +120,12 @@ export const TrustedAppsList = memo(() => { const pageSize = useTrustedAppsSelector(getListCurrentPageSize); const totalItemCount = useTrustedAppsSelector(getListTotalItemsCount); const listItems = useTrustedAppsSelector(getListItems); + const dispatch = useDispatch(); const history = useHistory(); return ( getColumnDefinitions(dispatch), [dispatch])} items={useMemo(() => [...listItems], [listItems])} error={useTrustedAppsSelector(getListErrorMessage)} loading={useTrustedAppsSelector(isListLoading)} diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_notifications.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_notifications.test.tsx new file mode 100644 index 0000000000000..cc45abf493582 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_notifications.test.tsx @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { Provider } from 'react-redux'; +import { render } from '@testing-library/react'; + +import { NotificationsStart } from 'kibana/public'; + +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { KibanaContextProvider } from '../../../../../../../../src/plugins/kibana_react/public/context'; + +import { + createGlobalNoMiddlewareStore, + createSampleTrustedApp, + createServerApiError, +} from '../test_utils'; + +import { TrustedAppsNotifications } from './trusted_apps_notifications'; + +const mockNotifications = () => coreMock.createStart({ basePath: '/mock' }).notifications; + +const renderNotifications = ( + store: ReturnType, + notifications: NotificationsStart +) => { + const Wrapper: React.FC = ({ children }) => ( + + {children} + + ); + + return render(, { wrapper: Wrapper }); +}; + +describe('TrustedAppsNotifications', () => { + it('renders correctly initially', () => { + const notifications = mockNotifications(); + + renderNotifications(createGlobalNoMiddlewareStore(), notifications); + + expect(notifications.toasts.addSuccess).not.toBeCalled(); + expect(notifications.toasts.addDanger).not.toBeCalled(); + }); + + it('shows success notification when deletion successful', () => { + const store = createGlobalNoMiddlewareStore(); + const notifications = mockNotifications(); + + renderNotifications(store, notifications); + + store.dispatch({ + type: 'trustedAppDeletionDialogStarted', + payload: { entry: createSampleTrustedApp(3) }, + }); + store.dispatch({ + type: 'trustedAppDeletionSubmissionResourceStateChanged', + payload: { newState: { type: 'LoadedResourceState', data: null } }, + }); + store.dispatch({ + type: 'trustedAppDeletionDialogClosed', + }); + + expect(notifications.toasts.addSuccess).toBeCalledWith({ + text: '"trusted app 3" has been removed from the Trusted Applications list.', + title: 'Successfully removed', + }); + expect(notifications.toasts.addDanger).not.toBeCalled(); + }); + + it('shows error notification when deletion fails', () => { + const store = createGlobalNoMiddlewareStore(); + const notifications = mockNotifications(); + + renderNotifications(store, notifications); + + store.dispatch({ + type: 'trustedAppDeletionDialogStarted', + payload: { entry: createSampleTrustedApp(3) }, + }); + store.dispatch({ + type: 'trustedAppDeletionSubmissionResourceStateChanged', + payload: { + newState: { type: 'FailedResourceState', error: createServerApiError('Not Found') }, + }, + }); + + expect(notifications.toasts.addSuccess).not.toBeCalled(); + expect(notifications.toasts.addDanger).toBeCalledWith({ + text: + 'Unable to remove "trusted app 3" from the Trusted Applications list. Reason: Not Found', + title: 'Removal failure', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_notifications.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_notifications.tsx new file mode 100644 index 0000000000000..9c0fe8eb6f0cb --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_notifications.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { memo } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { ServerApiError } from '../../../../common/types'; +import { Immutable, TrustedApp } from '../../../../../common/endpoint/types'; +import { getDeletionDialogEntry, getDeletionError, isDeletionSuccessful } from '../store/selectors'; + +import { useToasts } from '../../../../common/lib/kibana'; +import { useTrustedAppsSelector } from './hooks'; + +const getDeletionErrorMessage = (error: ServerApiError, entry: Immutable) => { + return { + title: i18n.translate('xpack.securitySolution.trustedapps.deletionError.title', { + defaultMessage: 'Removal failure', + }), + text: i18n.translate('xpack.securitySolution.trustedapps.deletionError.text', { + defaultMessage: + 'Unable to remove "{name}" from the Trusted Applications list. Reason: {message}', + values: { name: entry.name, message: error.message }, + }), + }; +}; + +const getDeletionSuccessMessage = (entry: Immutable) => { + return { + title: i18n.translate('xpack.securitySolution.trustedapps.deletionSuccess.title', { + defaultMessage: 'Successfully removed', + }), + text: i18n.translate('xpack.securitySolution.trustedapps.deletionSuccess.text', { + defaultMessage: '"{name}" has been removed from the Trusted Applications list.', + values: { name: entry?.name }, + }), + }; +}; + +export const TrustedAppsNotifications = memo(() => { + const deletionError = useTrustedAppsSelector(getDeletionError); + const deletionDialogEntry = useTrustedAppsSelector(getDeletionDialogEntry); + const deletionSuccessful = useTrustedAppsSelector(isDeletionSuccessful); + const toasts = useToasts(); + + if (deletionError && deletionDialogEntry) { + toasts.addDanger(getDeletionErrorMessage(deletionError, deletionDialogEntry)); + } + + if (deletionSuccessful && deletionDialogEntry) { + toasts.addSuccess(getDeletionSuccessMessage(deletionDialogEntry)); + } + + return <>; +}); + +TrustedAppsNotifications.displayName = 'TrustedAppsNotifications'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index 218cef36ed50a..457f96dbff768 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -21,6 +21,15 @@ describe('TrustedAppsPage', () => { let coreStart: AppContextTestRender['coreStart']; let waitForAction: MiddlewareActionSpyHelper['waitForAction']; let render: () => ReturnType; + const originalScrollTo = window.scrollTo; + + beforeAll(() => { + window.scrollTo = () => {}; + }); + + afterAll(() => { + window.scrollTo = originalScrollTo; + }); beforeEach(() => { const mockedContext = createAppRootMockRenderer(); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx index a0dae900eb30e..c1c23a3960962 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx @@ -9,6 +9,8 @@ import { EuiButton } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; import { AdministrationListPage } from '../../../components/administration_list_page'; import { TrustedAppsList } from './trusted_apps_list'; +import { TrustedAppDeletionDialog } from './trusted_app_deletion_dialog'; +import { TrustedAppsNotifications } from './trusted_apps_notifications'; import { CreateTrustedAppFlyout } from './components/create_trusted_app_flyout'; import { getTrustedAppsListPath } from '../../../common/routing'; import { useTrustedAppsSelector } from './hooks'; @@ -63,6 +65,8 @@ export const TrustedAppsPage = memo(() => { } actions={addButton} > + + {showAddFlout && ( = { [MANAGEMENT_STORE_POLICY_LIST_NAMESPACE]: initialPolicyListState(), [MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE]: initialPolicyDetailsState(), [MANAGEMENT_STORE_ENDPOINTS_NAMESPACE]: initialEndpointListState, - [MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE]: initialTrustedAppsPageState, + [MANAGEMENT_STORE_TRUSTED_APPS_NAMESPACE]: initialTrustedAppsPageState(), }; /** diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts index ec4d1efb81b11..7cf3d467c10c2 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.ts @@ -6,6 +6,7 @@ import { RequestHandler, RequestHandlerContext } from 'kibana/server'; import { + DeleteTrustedAppsRequestParams, GetTrustedAppsListRequest, GetTrustedListAppsResponse, PostTrustedAppCreateRequest, @@ -13,7 +14,6 @@ import { import { EndpointAppContext } from '../../types'; import { exceptionItemToTrustedAppItem, newTrustedAppItemToExceptionItem } from './utils'; import { ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../../../../lists/common/constants'; -import { DeleteTrustedAppsRequestParams } from './types'; import { ExceptionListClient } from '../../../../../lists/server'; const exceptionListClientFromContext = (context: RequestHandlerContext): ExceptionListClient => { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/trusted_apps.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/trusted_apps.test.ts index 2368dcda09a38..98c9b79f32d6b 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/trusted_apps.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/trusted_apps.test.ts @@ -18,6 +18,7 @@ import { TRUSTED_APPS_LIST_API, } from '../../../../common/endpoint/constants'; import { + DeleteTrustedAppsRequestParams, GetTrustedAppsListRequest, PostTrustedAppCreateRequest, } from '../../../../common/endpoint/types'; @@ -30,7 +31,6 @@ import { ExceptionListItemSchema, FoundExceptionListItemSchema, } from '../../../../../lists/common/schemas/response'; -import { DeleteTrustedAppsRequestParams } from './types'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; type RequestHandlerContextWithLists = ReturnType & { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/types.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/types.ts deleted file mode 100644 index 13c8bcfc20793..0000000000000 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * 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 { TypeOf } from '@kbn/config-schema'; -import { DeleteTrustedAppsRequestSchema } from '../../../../common/endpoint/schema/trusted_apps'; - -export type DeleteTrustedAppsRequestParams = TypeOf;