Skip to content

Commit

Permalink
[Security Solution] [Attack discovery] Fixes Attack discovery loading…
Browse files Browse the repository at this point in the history
… popover issues (#183675)

## [Security Solution] [Attack discovery] Fixes Attack discovery loading popover issues

### Summary

This PR fixes issues related to the Attack discovery loading popover, where it may not be displayed, or get stuck in an open state

### Desk testing

1) Configure at least two generative AI connectors

2) Clear local storage

3) Close all open browser tabs with sessions to Kibana

4) Navigate to Security > Attack discovery

5) Select the first connector

6) Click Generate

**Expected results**

- While loading, the information (i) icon (popover anchor) will NOT be displayed, because it's the first generation interval for the selected connector
- Attack discoveries are generated for the selected connector

7) Once again, click Generate

**Expected result**

- While loading, the information (i) icon (popover anchor) IS displayed, because there's now at least one generation interval available in local storage

8) Click the information (i) icon

**Expected result**

- The popover containing the text `Remaining time is based on the average speed...` is displayed
- The popover includes the time of the last generation

9) Click anywhere outside the popover

**Expected results**

- The popover closes

10) Keep opening and closing the popover while the selected connector is loading

**Expected results**

- The popover continues to open and close as expected
- Attack discoveries are once again generated for the currently selected connector

11) Select the other (2nd) connector

**Expected results**

- The `Up to 20 alerts will be analyzed` empty state is displayed

12) Click Generate for the newly-selected connector

**Expected results**

- While loading, the information (i) icon (popover anchor) will NOT be displayed, because it's the first generation interval for the newly selected connector
- Attack discoveries are generated for the newly selected connector

13) Once again, click Generate

**Expected result**

- While loading, the information (i) icon (popover anchor) IS displayed, because there's now at least one generation interval available in local storage for the selected (2nd) connector

14) Click on the information (i) icon (popover anchor)

**Expected result**

- The popover containing the text `Remaining time is based on the average speed...` is displayed
- The popover contains one date (for the previous generation)

15) Click anywhere outside the popover

**Expected results**

- The popover closes

16) While the 2nd connector is STILL loading, select the first connector

**Expected result**

- The cached attack discovery results for the first connector are displayed
- The loading callout is hidden
- The Generate button is disabled, and instead displays the text `Loading...`

17) While the 2nd connector is STILL loading, re-select the 2nd connector

**Expected result**

- The countdown timer is once again displayed, because the 2nd connector's was still loading
- Attack discoveries are generated for the 2nd connector

18) Close the browser

19) Open the browser

20) Navigate to Security > Attack discovery

**Expected result**

- The 2nd connector is still selected

21) Click Generate

**Expected result**

- While loading, the information (i) icon (popover anchor) will be displayed, because it's using the results from local storage

22) Click on the information (i) icon (popover anchor)

**Expected result**

- The popover containing the text `Remaining time is based on the average speed...` is displayed
- The popover contains two datetimes, for the two previous runs of the 2nd connector

23) Click anywhere outside the popover

**Expected results**

- The popover closes
  • Loading branch information
andrew-goldstein committed May 16, 2024
1 parent ea23d8d commit fd4de54
Show file tree
Hide file tree
Showing 3 changed files with 204 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiOutsideClickDetector,
EuiPopover,
EuiText,
useEuiTheme,
Expand Down Expand Up @@ -80,15 +81,17 @@ const CountdownComponent: React.FC<Props> = ({ approximateFutureTime, connectorI
justifyContent="spaceBetween"
>
<EuiFlexItem grow={false}>
<EuiPopover
anchorPosition="upCenter"
button={iconInQuestionButton}
closePopover={closePopover}
data-test-subj="infoPopover"
isOpen={isPopoverOpen}
>
<InfoPopoverBody connectorIntervals={connectorIntervals} />
</EuiPopover>
<EuiOutsideClickDetector isDisabled={!isPopoverOpen} onOutsideClick={() => closePopover()}>
<EuiPopover
anchorPosition="upCenter"
button={iconInQuestionButton}
closePopover={closePopover}
data-test-subj="infoPopover"
isOpen={isPopoverOpen}
>
<InfoPopoverBody connectorIntervals={connectorIntervals} />
</EuiPopover>
</EuiOutsideClickDetector>
</EuiFlexItem>

<EuiFlexItem grow={false}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import type { GenerationInterval } from '../../types';
import {
encodeGenerationIntervals,
decodeGenerationIntervals,
getLocalStorageGenerationIntervals,
setLocalStorageGenerationIntervals,
} from '.';

const key = 'elasticAssistantDefault.attackDiscovery.default.generationIntervals';

const generationIntervals: Record<string, GenerationInterval[]> = {
'test-connector-1': [
{
connectorId: 'test-connector-1',
date: new Date('2024-05-16T14:13:09.838Z'),
durationMs: 173648,
},
{
connectorId: 'test-connector-1',
date: new Date('2024-05-16T13:59:49.620Z'),
durationMs: 146605,
},
{
connectorId: 'test-connector-1',
date: new Date('2024-05-16T13:47:00.629Z'),
durationMs: 255163,
},
],
testConnector2: [
{
connectorId: 'testConnector2',
date: new Date('2024-05-16T14:26:25.273Z'),
durationMs: 130447,
},
],
testConnector3: [
{
connectorId: 'testConnector3',
date: new Date('2024-05-16T14:36:53.171Z'),
durationMs: 46614,
},
{
connectorId: 'testConnector3',
date: new Date('2024-05-16T14:27:17.187Z'),
durationMs: 44129,
},
],
};

describe('storage', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('encodeGenerationIntervals', () => {
it('returns null when generationIntervals is invalid', () => {
const invalidGenerationIntervals: Record<string, GenerationInterval[]> =
1n as unknown as Record<string, GenerationInterval[]>; // <-- invalid

const result = encodeGenerationIntervals(invalidGenerationIntervals);

expect(result).toBeNull();
});

it('returns the expected encoded generationIntervals', () => {
const result = encodeGenerationIntervals(generationIntervals);

expect(result).toEqual(JSON.stringify(generationIntervals));
});
});

describe('decodeGenerationIntervals', () => {
it('returns null when generationIntervals is invalid', () => {
const invalidGenerationIntervals = 'invalid generation intervals'; // <-- invalid

const result = decodeGenerationIntervals(invalidGenerationIntervals);

expect(result).toBeNull();
});

it('returns the expected decoded generation intervals', () => {
const encoded = encodeGenerationIntervals(generationIntervals) ?? ''; // <-- valid intervals

const result = decodeGenerationIntervals(encoded);

expect(result).toEqual(generationIntervals);
});

it('parses date strings into Date objects', () => {
const encoded = JSON.stringify({
'test-connector-1': [
{
connectorId: 'test-connector-1',
date: '2024-05-16T14:13:09.838Z',
durationMs: 173648,
},
],
});

const result = decodeGenerationIntervals(encoded);

expect(result).toEqual({
'test-connector-1': [
{
connectorId: 'test-connector-1',
date: new Date('2024-05-16T14:13:09.838Z'),
durationMs: 173648,
},
],
});
});

it('returns null when date is not a string', () => {
const encoded = JSON.stringify({
'test-connector-1': [
{
connectorId: 'test-connector-1',
date: 1234, // <-- invalid
durationMs: 173648,
},
],
});

const result = decodeGenerationIntervals(encoded);

expect(result).toBeNull();
});
});

describe('getLocalStorageGenerationIntervals', () => {
it('returns null when the key is empty', () => {
const result = getLocalStorageGenerationIntervals(''); // <-- empty key

expect(result).toBeNull();
});

it('returns null the key is unknown', () => {
const result = getLocalStorageGenerationIntervals('unknown key'); // <-- unknown key

expect(result).toBeNull();
});

it('returns null when the generation intervals are invalid', () => {
localStorage.setItem(key, 'invalid generation intervals'); // <-- invalid

const result = getLocalStorageGenerationIntervals(key);

expect(result).toBeNull();
});

it('returns the expected decoded generation intervals', () => {
const encoded = encodeGenerationIntervals(generationIntervals) ?? ''; // <-- valid intervals
localStorage.setItem(key, encoded);

const decoded = decodeGenerationIntervals(encoded);
const result = getLocalStorageGenerationIntervals(key);

expect(result).toEqual(decoded);
});
});

describe('setLocalStorageGenerationIntervals', () => {
const localStorageSetItemSpy = jest.spyOn(Storage.prototype, 'setItem');

it('sets the encoded generation intervals in localStorage', () => {
const encoded = encodeGenerationIntervals(generationIntervals) ?? '';

setLocalStorageGenerationIntervals({ key, generationIntervals });

expect(localStorageSetItemSpy).toHaveBeenCalledWith(key, encoded);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,18 @@ export const encodeGenerationIntervals = (
export const decodeGenerationIntervals = (
generationIntervals: string
): Record<string, GenerationInterval[]> | null => {
const parseDate = (key: string, value: unknown) => {
if (key === 'date' && typeof value === 'string') {
return new Date(value);
} else if (key === 'date' && typeof value !== 'string') {
throw new Error('Invalid date');
} else {
return value;
}
};

try {
return JSON.parse(generationIntervals);
return JSON.parse(generationIntervals, parseDate);
} catch {
return null;
}
Expand All @@ -87,7 +97,7 @@ export const getLocalStorageGenerationIntervals = (
key: string
): Record<string, GenerationInterval[]> | null => {
if (!isEmpty(key)) {
return decodeGenerationIntervals(sessionStorage.getItem(key) ?? '');
return decodeGenerationIntervals(localStorage.getItem(key) ?? '');
}

return null;
Expand Down

0 comments on commit fd4de54

Please sign in to comment.