[SIEM] Enhance URL to handle ISO-8601 date times within them. (#39045) (#39128)

## Summary
* Added ISO Handler called normalizeTimeRange
* Added unit tests
* Implemented and tested with Machine Learning Jobs to ensure it works as expected
* Moved around Omit Utility to the common area
* https://xkcd.com/1179/
### Checklist

Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR.

~~- [ ] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility)~~
~~- [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)~~
~~- [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~~

- [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios

~~- [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~~

### For maintainers

~~- [ ] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~~
~~- [ ] This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~~
This commit is contained in:
Frank Hassanabad 2019-06-17 20:07:25 -06:00 committed by GitHub
parent cf0b1200ab
commit 1b10a02cf9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 426 additions and 73 deletions

View file

@ -7,3 +7,5 @@
export type Pick3<T, K1 extends keyof T, K2 extends keyof T[K1], K3 extends keyof T[K1][K2]> = {
[P1 in K1]: { [P2 in K2]: { [P3 in K3]: ((T[K1])[K2])[P3] } }
};
export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

View file

@ -9,6 +9,7 @@ import * as React from 'react';
import { pure } from 'recompose';
import styled from 'styled-components';
import { Omit } from '../../../common/utility_types';
import { DragEffects, DraggableWrapper } from '../drag_and_drop/draggable_wrapper';
import { escapeDataProviderId } from '../drag_and_drop/helpers';
import { getEmptyStringTag } from '../empty_value';
@ -120,8 +121,6 @@ const Badge = styled(EuiBadge)`
vertical-align: top;
`;
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
export type BadgeDraggableType = Omit<DefaultDraggableType, 'id'> & {
contextId: string;
eventId: string;

View file

@ -12,7 +12,7 @@ import * as React from 'react';
import { AppTestingFrameworkAdapter } from '../../lib/adapters/framework/testing_framework_adapter';
import { mockFrameworks, TestProviders } from '../../mock';
import { PreferenceFormattedDate, FormattedDate, getMaybeDate } from '.';
import { PreferenceFormattedDate, FormattedDate } from '.';
import { getEmptyValue } from '../empty_value';
import { KibanaConfigContext } from '../../lib/adapters/framework/kibana_framework_adapter';
@ -155,56 +155,4 @@ describe('formatted_date', () => {
});
});
});
describe('getMaybeDate', () => {
test('returns empty string as invalid date', () => {
expect(getMaybeDate('').isValid()).toBe(false);
});
test('returns string with empty spaces as invalid date', () => {
expect(getMaybeDate(' ').isValid()).toBe(false);
});
test('returns string date time as valid date', () => {
expect(getMaybeDate('2019-05-28T23:05:28.405Z').isValid()).toBe(true);
});
test('returns string date time as the date we expect', () => {
expect(getMaybeDate('2019-05-28T23:05:28.405Z').toISOString()).toBe(
'2019-05-28T23:05:28.405Z'
);
});
test('returns plain string number as epoch as valid date', () => {
expect(getMaybeDate('1559084770612').isValid()).toBe(true);
});
test('returns plain string number as the date we expect', () => {
expect(
getMaybeDate('1559084770612')
.toDate()
.toISOString()
).toBe('2019-05-28T23:06:10.612Z');
});
test('returns plain number as epoch as valid date', () => {
expect(getMaybeDate(1559084770612).isValid()).toBe(true);
});
test('returns plain number as epoch as the date we expect', () => {
expect(
getMaybeDate(1559084770612)
.toDate()
.toISOString()
).toBe('2019-05-28T23:06:10.612Z');
});
test('returns a short date time string as an epoch (sadly) so this is ambiguous', () => {
expect(
getMaybeDate('20190101')
.toDate()
.toISOString()
).toBe('1970-01-01T05:36:30.101Z');
});
});
});

View file

@ -9,13 +9,13 @@ import * as React from 'react';
import { useContext } from 'react';
import { pure } from 'recompose';
import { isString } from 'lodash/fp';
import {
AppKibanaFrameworkAdapter,
KibanaConfigContext,
} from '../../lib/adapters/framework/kibana_framework_adapter';
import { getOrEmptyTagFromValue } from '../empty_value';
import { LocalizedDateTooltip } from '../localized_date_tooltip';
import { getMaybeDate } from './maybe_date';
export const PreferenceFormattedDate = pure<{ value: Date }>(({ value }) => {
const config: Partial<AppKibanaFrameworkAdapter> = useContext(KibanaConfigContext);
@ -30,19 +30,6 @@ export const PreferenceFormattedDate = pure<{ value: Date }>(({ value }) => {
);
});
export const getMaybeDate = (value: string | number): moment.Moment => {
if (isString(value) && value.trim() !== '') {
const maybeDate = moment(new Date(value));
if (maybeDate.isValid() || isNaN(+value)) {
return maybeDate;
} else {
return moment(new Date(+value));
}
} else {
return moment(new Date(value));
}
};
/**
* Renders the specified date value in a format determined by the user's preferences,
* with a tooltip that renders:

View file

@ -0,0 +1,57 @@
/*
* 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 { getMaybeDate } from './maybe_date';
describe('#getMaybeDate', () => {
test('returns empty string as invalid date', () => {
expect(getMaybeDate('').isValid()).toBe(false);
});
test('returns string with empty spaces as invalid date', () => {
expect(getMaybeDate(' ').isValid()).toBe(false);
});
test('returns string date time as valid date', () => {
expect(getMaybeDate('2019-05-28T23:05:28.405Z').isValid()).toBe(true);
});
test('returns string date time as the date we expect', () => {
expect(getMaybeDate('2019-05-28T23:05:28.405Z').toISOString()).toBe('2019-05-28T23:05:28.405Z');
});
test('returns plain string number as epoch as valid date', () => {
expect(getMaybeDate('1559084770612').isValid()).toBe(true);
});
test('returns plain string number as the date we expect', () => {
expect(
getMaybeDate('1559084770612')
.toDate()
.toISOString()
).toBe('2019-05-28T23:06:10.612Z');
});
test('returns plain number as epoch as valid date', () => {
expect(getMaybeDate(1559084770612).isValid()).toBe(true);
});
test('returns plain number as epoch as the date we expect', () => {
expect(
getMaybeDate(1559084770612)
.toDate()
.toISOString()
).toBe('2019-05-28T23:06:10.612Z');
});
test('returns a short date time string as an epoch (sadly) so this is ambiguous', () => {
expect(
getMaybeDate('20190101')
.toDate()
.toISOString()
).toBe('1970-01-01T05:36:30.101Z');
});
});

View file

@ -0,0 +1,21 @@
/*
* 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 { isString } from 'lodash/fp';
import moment from 'moment';
export const getMaybeDate = (value: string | number): moment.Moment => {
if (isString(value) && value.trim() !== '') {
const maybeDate = moment(new Date(value));
if (maybeDate.isValid() || isNaN(+value)) {
return maybeDate;
} else {
return moment(new Date(+value));
}
} else {
return moment(new Date(value));
}
};

View file

@ -48,6 +48,7 @@ import {
} from './types';
import { CONSTANTS } from './constants';
import { InputsModelId, TimeRangeKinds } from '../../store/inputs/constants';
import { normalizeTimeRange } from './normalize_time_range';
export class UrlStateContainerLifecycle extends React.Component<UrlStateContainerPropTypes> {
public render() {
@ -123,14 +124,18 @@ export class UrlStateContainerLifecycle extends React.Component<UrlStateContaine
this.props.toggleTimelineLinkTo({ linkToId: 'global' });
}
if (globalType === 'absolute') {
const absoluteRange: AbsoluteTimeRange = get('global.timerange', timerangeStateData);
const absoluteRange = normalizeTimeRange<AbsoluteTimeRange>(
get('global.timerange', timerangeStateData)
);
this.props.setAbsoluteTimerange({
...absoluteRange,
id: globalId,
});
}
if (globalType === 'relative') {
const relativeRange: RelativeTimeRange = get('global.timerange', timerangeStateData);
const relativeRange = normalizeTimeRange<RelativeTimeRange>(
get('global.timerange', timerangeStateData)
);
this.props.setRelativeTimerange({
...relativeRange,
id: globalId,
@ -145,14 +150,18 @@ export class UrlStateContainerLifecycle extends React.Component<UrlStateContaine
this.props.toggleTimelineLinkTo({ linkToId: 'timeline' });
}
if (timelineType === 'absolute') {
const absoluteRange: AbsoluteTimeRange = get('timeline.timerange', timerangeStateData);
const absoluteRange = normalizeTimeRange<AbsoluteTimeRange>(
get('timeline.timerange', timerangeStateData)
);
this.props.setAbsoluteTimerange({
...absoluteRange,
id: timelineId,
});
}
if (timelineType === 'relative') {
const relativeRange: RelativeTimeRange = get('timeline.timerange', timerangeStateData);
const relativeRange = normalizeTimeRange<RelativeTimeRange>(
get('timeline.timerange', timerangeStateData)
);
this.props.setRelativeTimerange({
...relativeRange,
id: timelineId,

View file

@ -0,0 +1,296 @@
/*
* 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 { normalizeTimeRange } from './normalize_time_range';
import {
URLTimeRange,
AbsoluteTimeRange,
isAbsoluteTimeRange,
RelativeTimeRange,
isRelativeTimeRange,
} from '../../store/inputs/model';
describe('#normalizeTimeRange', () => {
test('Absolute time range returns empty strings as 0', () => {
const dateTimeRange: URLTimeRange = {
kind: 'absolute',
fromStr: undefined,
toStr: undefined,
from: '',
to: '',
};
if (isAbsoluteTimeRange(dateTimeRange)) {
const expected: AbsoluteTimeRange = {
kind: 'absolute',
from: 0,
to: 0,
fromStr: undefined,
toStr: undefined,
};
expect(normalizeTimeRange<AbsoluteTimeRange>(dateTimeRange)).toEqual(expected);
} else {
throw new Error('Was expecting date time range to be a AbsoluteTimeRange');
}
});
test('Absolute time range returns string with empty spaces as 0', () => {
const dateTimeRange: URLTimeRange = {
kind: 'absolute',
fromStr: undefined,
toStr: undefined,
from: ' ',
to: ' ',
};
if (isAbsoluteTimeRange(dateTimeRange)) {
const expected: AbsoluteTimeRange = {
kind: 'absolute',
from: 0,
to: 0,
fromStr: undefined,
toStr: undefined,
};
expect(normalizeTimeRange<AbsoluteTimeRange>(dateTimeRange)).toEqual(expected);
} else {
throw new Error('Was expecting date time range to be a AbsoluteTimeRange');
}
});
test('Absolute time range returns string date time as valid date with from and to as ISO strings', () => {
const to = new Date('2019-04-28T23:05:28.405Z');
const from = new Date('2019-05-28T23:05:28.405Z');
const dateTimeRange: URLTimeRange = {
kind: 'absolute',
fromStr: undefined,
toStr: undefined,
from: from.toISOString(),
to: to.toISOString(),
};
if (isAbsoluteTimeRange(dateTimeRange)) {
const expected: AbsoluteTimeRange = {
kind: 'absolute',
from: from.valueOf(),
to: to.valueOf(),
fromStr: undefined,
toStr: undefined,
};
expect(normalizeTimeRange<AbsoluteTimeRange>(dateTimeRange)).toEqual(expected);
} else {
throw new Error('Was expecting date time range to be a AbsoluteTimeRange');
}
});
test('Absolute time range returns number as valid date with from and to as Epoch', () => {
const to = new Date('2019-04-28T23:05:28.405Z');
const from = new Date('2019-05-28T23:05:28.405Z');
const dateTimeRange: URLTimeRange = {
kind: 'absolute',
fromStr: undefined,
toStr: undefined,
from: from.valueOf(),
to: to.valueOf(),
};
if (isAbsoluteTimeRange(dateTimeRange)) {
const expected: AbsoluteTimeRange = {
kind: 'absolute',
from: from.valueOf(),
to: to.valueOf(),
fromStr: undefined,
toStr: undefined,
};
expect(normalizeTimeRange<AbsoluteTimeRange>(dateTimeRange)).toEqual(expected);
} else {
throw new Error('Was expecting date time range to be a AbsoluteTimeRange');
}
});
test('Absolute time range returns number as valid date with from and to as Epoch when the Epoch is a string', () => {
const to = new Date('2019-04-28T23:05:28.405Z');
const from = new Date('2019-05-28T23:05:28.405Z');
const dateTimeRange: URLTimeRange = {
kind: 'absolute',
fromStr: undefined,
toStr: undefined,
from: `${from.valueOf()}`,
to: `${to.valueOf()}`,
};
if (isAbsoluteTimeRange(dateTimeRange)) {
const expected: AbsoluteTimeRange = {
kind: 'absolute',
from: from.valueOf(),
to: to.valueOf(),
fromStr: undefined,
toStr: undefined,
};
expect(normalizeTimeRange<AbsoluteTimeRange>(dateTimeRange)).toEqual(expected);
} else {
throw new Error('Was expecting date time range to be a AbsoluteTimeRange');
}
});
test('Absolute time range returns NaN with from and to when garbage is sent in', () => {
const to = 'garbage';
const from = 'garbage';
const dateTimeRange: URLTimeRange = {
kind: 'absolute',
fromStr: undefined,
toStr: undefined,
from,
to,
};
if (isAbsoluteTimeRange(dateTimeRange)) {
const expected: AbsoluteTimeRange = {
kind: 'absolute',
from: NaN,
to: NaN,
fromStr: undefined,
toStr: undefined,
};
expect(normalizeTimeRange<AbsoluteTimeRange>(dateTimeRange)).toEqual(expected);
} else {
throw new Error('Was expecting date time range to be a AbsoluteTimeRange');
}
});
test('Relative time range returns empty strings as 0', () => {
const dateTimeRange: URLTimeRange = {
kind: 'relative',
fromStr: '',
toStr: '',
from: '',
to: '',
};
if (isRelativeTimeRange(dateTimeRange)) {
const expected: RelativeTimeRange = {
kind: 'relative',
from: 0,
to: 0,
fromStr: '',
toStr: '',
};
expect(normalizeTimeRange<RelativeTimeRange>(dateTimeRange)).toEqual(expected);
} else {
throw new Error('Was expecting date time range to be a RelativeTimeRange');
}
});
test('Relative time range returns string with empty spaces as 0', () => {
const dateTimeRange: URLTimeRange = {
kind: 'relative',
fromStr: '',
toStr: '',
from: ' ',
to: ' ',
};
if (isRelativeTimeRange(dateTimeRange)) {
const expected: RelativeTimeRange = {
kind: 'relative',
from: 0,
to: 0,
fromStr: '',
toStr: '',
};
expect(normalizeTimeRange<RelativeTimeRange>(dateTimeRange)).toEqual(expected);
} else {
throw new Error('Was expecting date time range to be a RelativeTimeRange');
}
});
test('Relative time range returns string date time as valid date with from and to as ISO strings', () => {
const to = new Date('2019-04-28T23:05:28.405Z');
const from = new Date('2019-05-28T23:05:28.405Z');
const dateTimeRange: URLTimeRange = {
kind: 'relative',
fromStr: '',
toStr: '',
from: from.toISOString(),
to: to.toISOString(),
};
if (isRelativeTimeRange(dateTimeRange)) {
const expected: RelativeTimeRange = {
kind: 'relative',
from: from.valueOf(),
to: to.valueOf(),
fromStr: '',
toStr: '',
};
expect(normalizeTimeRange<RelativeTimeRange>(dateTimeRange)).toEqual(expected);
} else {
throw new Error('Was expecting date time range to be a RelativeTimeRange');
}
});
test('Relative time range returns number as valid date with from and to as Epoch', () => {
const to = new Date('2019-04-28T23:05:28.405Z');
const from = new Date('2019-05-28T23:05:28.405Z');
const dateTimeRange: URLTimeRange = {
kind: 'relative',
fromStr: '',
toStr: '',
from: from.valueOf(),
to: to.valueOf(),
};
if (isRelativeTimeRange(dateTimeRange)) {
const expected: RelativeTimeRange = {
kind: 'relative',
from: from.valueOf(),
to: to.valueOf(),
fromStr: '',
toStr: '',
};
expect(normalizeTimeRange<RelativeTimeRange>(dateTimeRange)).toEqual(expected);
} else {
throw new Error('Was expecting date time range to be a RelativeTimeRange');
}
});
test('Relative time range returns number as valid date with from and to as Epoch when the Epoch is a string', () => {
const to = new Date('2019-04-28T23:05:28.405Z');
const from = new Date('2019-05-28T23:05:28.405Z');
const dateTimeRange: URLTimeRange = {
kind: 'relative',
fromStr: '',
toStr: '',
from: `${from.valueOf()}`,
to: `${to.valueOf()}`,
};
if (isRelativeTimeRange(dateTimeRange)) {
const expected: RelativeTimeRange = {
kind: 'relative',
from: from.valueOf(),
to: to.valueOf(),
fromStr: '',
toStr: '',
};
expect(normalizeTimeRange<RelativeTimeRange>(dateTimeRange)).toEqual(expected);
} else {
throw new Error('Was expecting date time range to be a RelativeTimeRange');
}
});
test('Relative time range returns NaN with from and to when garbage is sent in', () => {
const to = 'garbage';
const from = 'garbage';
const dateTimeRange: URLTimeRange = {
kind: 'relative',
fromStr: '',
toStr: '',
from,
to,
};
if (isRelativeTimeRange(dateTimeRange)) {
const expected: RelativeTimeRange = {
kind: 'relative',
from: NaN,
to: NaN,
fromStr: '',
toStr: '',
};
expect(normalizeTimeRange<RelativeTimeRange>(dateTimeRange)).toEqual(expected);
} else {
throw new Error('Was expecting date time range to be a RelativeTimeRange');
}
});
});

View file

@ -0,0 +1,20 @@
/*
* 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 { URLTimeRange } from '../../store/inputs/model';
import { getMaybeDate } from '../formatted_date/maybe_date';
export const normalizeTimeRange = <T extends URLTimeRange>(dateRange: T): T => {
const maybeTo = getMaybeDate(dateRange.to);
const maybeFrom = getMaybeDate(dateRange.from);
const to: number = maybeTo.isValid() ? maybeTo.valueOf() : Number(dateRange.to);
const from: number = maybeFrom.isValid() ? maybeFrom.valueOf() : Number(dateRange.from);
return {
...dateRange,
to,
from,
};
};

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Omit } from '../../../common/utility_types';
import { InputsModelId } from './constants';
import { CONSTANTS } from '../../components/url_state/constants';
@ -23,8 +24,21 @@ export interface RelativeTimeRange {
to: number;
}
export const isRelativeTimeRange = (
timeRange: RelativeTimeRange | AbsoluteTimeRange | URLTimeRange
): timeRange is RelativeTimeRange => timeRange.kind === 'relative';
export const isAbsoluteTimeRange = (
timeRange: RelativeTimeRange | AbsoluteTimeRange | URLTimeRange
): timeRange is AbsoluteTimeRange => timeRange.kind === 'absolute';
export type TimeRange = AbsoluteTimeRange | RelativeTimeRange;
export type URLTimeRange = Omit<TimeRange, 'from' | 'to'> & {
from: string | TimeRange['from'];
to: string | TimeRange['to'];
};
export interface Policy {
kind: 'manual' | 'interval';
duration: number; // in ms