Commit 66ecf82c authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by Igor Drozdov

Add scheduling options to DAST profiles

parent 7bfa0963
......@@ -299,8 +299,12 @@ export const dateToYearMonthDate = (date) => {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('Argument should be a Date instance');
}
const [year, month, day] = date.toISOString().replace(/T.*$/, '').split('-');
return { year, month, day };
const [month, day] = padWithZeros(date.getMonth() + 1, date.getDate());
return {
year: `${date.getFullYear()}`,
month,
day,
};
};
/**
......@@ -328,13 +332,15 @@ export const timeToHoursMinutes = (time = '') => {
* @param {String} offset An optional Date-compatible offset.
* @returns {String} The combined Date's ISO string representation.
*/
export const dateAndTimeToUTCString = (date, time, offset = '') => {
export const dateAndTimeToISOString = (date, time, offset = '') => {
const { year, month, day } = dateToYearMonthDate(date);
const { hours, minutes } = timeToHoursMinutes(time);
return new Date(
`${year}-${month}-${day}T${hours}:${minutes}:00.000${offset || 'Z'}`,
).toISOString();
const dateString = `${year}-${month}-${day}T${hours}:${minutes}:00.000${offset || 'Z'}`;
if (Number.isNaN(Date.parse(dateString))) {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('Could not initialize date');
}
return dateString;
};
/**
......
......@@ -66,7 +66,7 @@ export default {
};
</script>
<template>
<gl-dropdown :text="selectedTimezoneLabel" block lazy menu-class="gl-w-full!">
<gl-dropdown :text="selectedTimezoneLabel" block lazy menu-class="gl-w-full!" v-bind="$attrs">
<gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus />
<gl-dropdown-item
v-for="timezone in filteredResults"
......
......@@ -26,6 +26,7 @@ import RefSelector from '~/ref/components/ref_selector.vue';
import { REF_TYPE_BRANCHES } from '~/ref/constants';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import validation from '~/vue_shared/directives/validation';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import dastProfileCreateMutation from '../graphql/dast_profile_create.mutation.graphql';
import dastProfileUpdateMutation from '../graphql/dast_profile_update.mutation.graphql';
import {
......@@ -38,6 +39,7 @@ import {
} from '../settings';
import ScannerProfileSelector from './profile_selector/scanner_profile_selector.vue';
import SiteProfileSelector from './profile_selector/site_profile_selector.vue';
import ScanSchedule from './scan_schedule.vue';
export const ON_DEMAND_SCANS_STORAGE_KEY = 'on-demand-scans-new-form';
......@@ -69,6 +71,7 @@ export default {
RefSelector,
ScannerProfileSelector,
SiteProfileSelector,
ScanSchedule,
GlAlert,
GlButton,
GlCard,
......@@ -86,6 +89,7 @@ export default {
GlTooltip: GlTooltipDirective,
validation: validation(),
},
mixins: [glFeatureFlagMixin()],
apollo: {
scannerProfiles: createProfilesApolloOptions(
'scannerProfiles',
......@@ -130,6 +134,7 @@ export default {
selectedBranch: this.dastScan?.branch?.name ?? this.defaultBranch,
selectedScannerProfileId: this.dastScan?.dastScannerProfile.id || null,
selectedSiteProfileId: this.dastScan?.dastSiteProfile.id || null,
profileSchedule: this.dastScan?.dastProfileSchedule,
loading: false,
errorType: null,
errors: [],
......@@ -198,12 +203,18 @@ export default {
return isFormInvalid || (loading && loading !== saveScanBtnId);
},
formFieldValues() {
const { selectedScannerProfileId, selectedSiteProfileId, selectedBranch } = this;
const {
selectedScannerProfileId,
selectedSiteProfileId,
selectedBranch,
profileSchedule,
} = this;
return {
...serializeFormObject(this.form.fields),
selectedScannerProfileId,
selectedSiteProfileId,
selectedBranch,
profileSchedule,
};
},
storageKey() {
......@@ -236,6 +247,9 @@ export default {
dastScannerProfileId: this.selectedScannerProfile.id,
dastSiteProfileId: this.selectedSiteProfile.id,
branchName: this.selectedBranch,
...(this.glFeatures.dastOnDemandScansScheduler
? { dastProfileSchedule: this.profileSchedule }
: {}),
...(this.isEdit ? { id: this.dastScan.id } : {}),
...serializeFormObject(this.form.fields),
[this.isEdit ? 'runAfterUpdate' : 'runAfterCreate']: runAfter,
......@@ -286,6 +300,7 @@ export default {
const {
selectedSiteProfileId,
selectedScannerProfileId,
profileSchedule,
name,
description,
selectedBranch,
......@@ -297,6 +312,7 @@ export default {
// precedence is given to profile IDs passed from the query params
this.selectedSiteProfileId = this.selectedSiteProfileId ?? selectedSiteProfileId;
this.selectedScannerProfileId = this.selectedScannerProfileId ?? selectedScannerProfileId;
this.profileSchedule = this.profileSchedule ?? profileSchedule;
},
},
};
......@@ -436,6 +452,8 @@ export default {
:has-conflict="hasProfilesConflict"
/>
<scan-schedule v-if="glFeatures.dastOnDemandScansScheduler" v-model="profileSchedule" />
<gl-alert
v-if="hasProfilesConflict"
:title="s__('OnDemandScans|You cannot run an active scan against an unvalidated site.')"
......
<script>
import { GlCard, GlDatepicker, GlFormCheckbox, GlFormGroup } from '@gitlab/ui';
import DropdownInput from 'ee/security_configuration/components/dropdown_input.vue';
import {
dateAndTimeToISOString,
stripTimezoneFromISODate,
dateToTimeInputValue,
} from '~/lib/utils/datetime/date_format_utility';
import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown.vue';
import { SCAN_CADENCE_OPTIONS } from '../settings';
/**
* Converts a cadence option string into the proper schedule parameter.
* @param {String} str Cadence option's string representation.
* @returns {Object} Corresponding schedule parameter.
*/
const toGraphQLCadence = (str) => {
if (!str) {
return '';
}
const [unit, duration] = str.split('_');
return { unit, duration: Number(duration) };
};
/**
* Converts a schedule parameter into the corresponding string option.
* @param {Object} obj Schedule paramter.
* @returns {String} Corresponding cadence option's string representation.
*/
const fromGraphQLCadence = (obj) => {
if (!obj) {
return '';
}
return `${obj.unit}_${obj.duration}`.toUpperCase();
};
export default {
name: 'ScanSchedule',
components: {
GlCard,
GlDatepicker,
GlFormCheckbox,
GlFormGroup,
DropdownInput,
TimezoneDropdown,
},
inject: ['timezones'],
props: {
value: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
form: {
isScheduledScan: this.value.active ?? false,
selectedTimezone: this.value.timezone ?? null,
startDate: null,
startTime: null,
cadence: fromGraphQLCadence(this.value.cadence) ?? SCAN_CADENCE_OPTIONS[0].value,
},
};
},
computed: {
timezone: {
set(timezone) {
this.form.selectedTimezone = timezone.identifier;
},
get() {
return this.selectedTimezoneData?.name ?? '';
},
},
selectedTimezoneData() {
return this.form.selectedTimezone
? this.timezones.find(({ identifier }) => identifier === this.form.selectedTimezone)
: null;
},
},
created() {
const date = this.value.startsAt ?? null;
if (date !== null) {
const localeDate = new Date(
stripTimezoneFromISODate(date, this.selectedTimezoneData?.offset),
);
this.form.startDate = localeDate;
this.form.startTime = date ? dateToTimeInputValue(localeDate) : null;
}
},
methods: {
handleInput() {
const { startDate, startTime, cadence } = this.form;
let startsAt;
try {
startsAt = dateAndTimeToISOString(
startDate,
startTime,
this.selectedTimezoneData?.formatted_offset,
);
} catch (e) {
startsAt = null;
}
const input = {
active: this.form.isScheduledScan,
startsAt,
cadence: toGraphQLCadence(cadence),
timezone: this.selectedTimezoneData?.identifier ?? null,
};
this.$emit('input', input);
},
},
SCAN_CADENCE_OPTIONS,
};
</script>
<template>
<gl-card class="gl-bg-gray-10">
<div class="row">
<div class="col-12 col-md-6">
<gl-form-checkbox v-model="form.isScheduledScan" class="gl-mb-3" @input="handleInput">
<span class="gl-font-weight-bold">{{ s__('OnDemandScans|Schedule scan') }}</span>
</gl-form-checkbox>
<gl-form-group
class="gl-pl-6"
data-testid="profile-schedule-form-group"
:disabled="!form.isScheduledScan"
>
<div class="gl-font-weight-bold gl-mb-3">
{{ s__('OnDemandScans|Start time') }}
</div>
<timezone-dropdown
v-model="timezone"
:timezone-data="timezones"
:disabled="!form.isScheduledScan"
@input="handleInput"
/>
<div class="gl-display-flex gl-align-items-center">
<gl-datepicker v-model="form.startDate" @input="handleInput" />
<span class="gl-px-3">
{{ __('at') }}
</span>
<input
v-model="form.startTime"
type="time"
class="gl-form-input form-control"
@input="handleInput"
/>
</div>
<dropdown-input
v-model="form.cadence"
:label="__('Repeats')"
:default-text="__('Repeats')"
:options="$options.SCAN_CADENCE_OPTIONS"
:disabled="!form.isScheduledScan"
field="repeat-input"
class="gl-mt-5"
data-testid="schedule-cadence-input"
@input="handleInput"
/>
</gl-form-group>
</div>
</div>
</gl-card>
</template>
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import OnDemandScansForm from './components/on_demand_scans_form.vue';
import apolloProvider from './graphql/provider';
......@@ -19,8 +18,9 @@ export default () => {
newSiteProfilePath,
newScannerProfilePath,
helpPagePath,
dastScan,
} = el.dataset;
const dastScan = el.dataset.dastScan ? JSON.parse(el.dataset.dastScan) : null;
const timezones = JSON.parse(el.dataset.timezones);
return new Vue({
el,
......@@ -34,12 +34,13 @@ export default () => {
newScannerProfilePath,
newSiteProfilePath,
dastSiteValidationDocsPath,
timezones,
},
render(h) {
return h(OnDemandScansForm, {
props: {
defaultBranch,
dastScan: dastScan ? convertObjectPropsToCamelCase(JSON.parse(dastScan)) : null,
dastScan,
},
});
},
......
import dastScannerProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_scanner_profiles.query.graphql';
import dastSiteProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_site_profiles.query.graphql';
import { s__ } from '~/locale';
import { __, s__ } from '~/locale';
export const ERROR_RUN_SCAN = 'ERROR_RUN_SCAN';
export const ERROR_FETCH_SCANNER_PROFILES = 'ERROR_FETCH_SCANNER_PROFILES';
......@@ -27,3 +27,22 @@ export const SITE_PROFILES_QUERY = {
fetchQuery: dastSiteProfilesQuery,
fetchError: ERROR_FETCH_SITE_PROFILES,
};
/* eslint-disable @gitlab/require-i18n-strings */
const DAY_1 = 'DAY_1';
const WEEK_1 = 'WEEK_1';
const MONTH_1 = 'MONTH_1';
const MONTH_3 = 'MONTH_3';
const MONTH_6 = 'MONTH_6';
const YEAR_1 = 'YEAR_1';
/* eslint-enable @gitlab/require-i18n-strings */
export const SCAN_CADENCE_OPTIONS = [
{ value: '', text: __('Never') },
{ value: DAY_1, text: __('Every day') },
{ value: WEEK_1, text: __('Every week') },
{ value: MONTH_1, text: __('Every month') },
{ value: MONTH_3, text: __('Every 3 months') },
{ value: MONTH_6, text: __('Every 6 months') },
{ value: YEAR_1, text: __('Every year') },
];
......@@ -8,6 +8,10 @@ module Projects
before_action :authorize_read_on_demand_scans!, only: :index
before_action :authorize_create_on_demand_dast_scan!, only: [:new, :edit]
before_action do
push_frontend_feature_flag(:dast_on_demand_scans_scheduler, @project, default_enabled: :yaml)
end
feature_category :dynamic_application_security_testing
def index
......
......@@ -12,7 +12,8 @@ module Projects::OnDemandScansHelper
'scanner-profiles-library-path' => project_security_configuration_dast_scans_path(project, anchor: 'scanner-profiles'),
'site-profiles-library-path' => project_security_configuration_dast_scans_path(project, anchor: 'site-profiles'),
'new-scanner-profile-path' => new_project_security_configuration_dast_scans_dast_scanner_profile_path(project),
'new-site-profile-path' => new_project_security_configuration_dast_scans_dast_site_profile_path(project)
'new-site-profile-path' => new_project_security_configuration_dast_scans_dast_site_profile_path(project),
'timezones' => timezone_data(format: :full).to_json
}
end
end
......@@ -5,6 +5,7 @@ import VueApollo from 'vue-apollo';
import OnDemandScansForm from 'ee/on_demand_scans/components/on_demand_scans_form.vue';
import ScannerProfileSelector from 'ee/on_demand_scans/components/profile_selector/scanner_profile_selector.vue';
import SiteProfileSelector from 'ee/on_demand_scans/components/profile_selector/site_profile_selector.vue';
import ScanSchedule from 'ee/on_demand_scans/components/scan_schedule.vue';
import dastProfileCreateMutation from 'ee/on_demand_scans/graphql/dast_profile_create.mutation.graphql';
import dastProfileUpdateMutation from 'ee/on_demand_scans/graphql/dast_profile_update.mutation.graphql';
import dastScannerProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_scanner_profiles.query.graphql';
......@@ -44,11 +45,12 @@ const dastScan = {
};
useLocalStorageSpy();
jest.mock('~/lib/utils/url_utility', () => ({
isAbsolute: jest.requireActual('~/lib/utils/url_utility').isAbsolute,
queryToObject: jest.requireActual('~/lib/utils/url_utility').queryToObject,
redirectTo: jest.fn(),
}));
jest.mock('~/lib/utils/url_utility', () => {
return {
...jest.requireActual('~/lib/utils/url_utility'),
redirectTo: jest.fn(),
};
});
const LOCAL_STORAGE_KEY = 'group/project/on-demand-scans-new-form';
......@@ -161,11 +163,15 @@ describe('OnDemandScansForm', () => {
newScannerProfilePath,
newSiteProfilePath,
dastSiteValidationDocsPath,
glFeatures: {
dastOnDemandScansScheduler: true,
},
},
stubs: {
GlFormInput: GlFormInputStub,
RefSelector: RefSelectorStub,
LocalStorageSync,
ScanSchedule: true,
},
},
{ ...options, localVue, apolloProvider },
......@@ -201,6 +207,7 @@ describe('OnDemandScansForm', () => {
createComponent();
expect(wrapper.text()).toContain('New on-demand DAST scan');
expect(wrapper.findComponent(ScanSchedule).exists()).toBe(true);
});
it('populates the branch input with the default branch', () => {
......@@ -657,4 +664,16 @@ describe('OnDemandScansForm', () => {
);
});
});
it('does not render scan schedule when the feature flag is disabled', () => {
createComponent({
provide: {
glFeatures: {
dastOnDemandScansScheduler: false,
},
},
});
expect(wrapper.findComponent(ScanSchedule).exists()).toBe(false);
});
});
import { GlDatepicker, GlFormCheckbox, GlFormGroup } from '@gitlab/ui';
import { merge } from 'lodash';
import ScanSchedule from 'ee/on_demand_scans/components/scan_schedule.vue';
import { SCAN_CADENCE_OPTIONS } from 'ee/on_demand_scans/settings';
import DropdownInput from 'ee/security_configuration/components/dropdown_input.vue';
import { stubComponent } from 'helpers/stub_component';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown.vue';
const mockTimezones = getJSONFixture('timezones/full.json');
const timezoneSST = mockTimezones[2];
describe('ScanSchedule', () => {
let wrapper;
// Finders
const findCheckbox = () => wrapper.findComponent(GlFormCheckbox);
const findProfileScheduleFormGroup = () => wrapper.findByTestId('profile-schedule-form-group');
const findTimezoneDropdown = () => wrapper.findComponent(TimezoneDropdown);
const findDatepicker = () => wrapper.findComponent(GlDatepicker);
const findTimeInput = () => wrapper.find('input[type="time"]');
const findCadenceInput = () => wrapper.findComponent(DropdownInput);
// Helpers
const setTimeInputValue = (value) => {
const input = findTimeInput();
input.element.value = value;
input.trigger('input');
return wrapper.vm.$nextTick();
};
const createComponent = (options = {}) => {
wrapper = shallowMountExtended(
ScanSchedule,
merge(
{
provide: {
timezones: mockTimezones,
},
stubs: {
GlFormGroup: stubComponent(GlFormGroup, {
props: ['disabled'],
}),
GlFormCheckbox: stubComponent(GlFormCheckbox, {
props: ['checked'],
}),
TimezoneDropdown: stubComponent(TimezoneDropdown, {
props: ['disabled', 'timezoneData', 'value'],
}),
},
},
options,
),
);
};
afterEach(() => {
wrapper.destroy();
});
describe('default state', () => {
beforeEach(() => {
createComponent();
});
it('by default, checkbox is unchecked and fields are disabled', () => {
expect(findCheckbox().props('checked')).toBe(false);
expect(findProfileScheduleFormGroup().props('disabled')).toBe(true);
expect(findTimezoneDropdown().props('disabled')).toBe(true);
expect(findCadenceInput().props('disabled')).toBe(true);
});
it('initializes timezone dropdown properly', () => {
const timezoneDropdown = findTimezoneDropdown();
expect(timezoneDropdown.props('timezoneData')).toEqual(mockTimezones);
expect(timezoneDropdown.props('value')).toBe('');
});
});
describe('once schedule is activated', () => {
beforeEach(() => {
createComponent();
findCheckbox().vm.$emit('input', true);
});
it('enables fields', () => {
expect(findTimezoneDropdown().attributes('disabled')).toBeUndefined();
expect(findProfileScheduleFormGroup().props('disabled')).toBe(false);
expect(findTimezoneDropdown().props('disabled')).toBe(false);
expect(findCadenceInput().props('disabled')).toBe(false);
});
it('emits input payload', () => {
expect(wrapper.emitted().input).toHaveLength(1);
expect(wrapper.emitted().input[0]).toEqual([
{
active: true,
cadence: SCAN_CADENCE_OPTIONS[0].value,
startsAt: null,
timezone: null,
},
]);
});
it('computes start date when datepicker and time input are changed', async () => {
findDatepicker().vm.$emit('input', new Date('2021-08-12'));
await setTimeInputValue('11:00');
expect(wrapper.emitted().input).toHaveLength(3);
expect(wrapper.emitted().input[2]).toEqual([
{
active: true,
cadence: SCAN_CADENCE_OPTIONS[0].value,
startsAt: '2021-08-12T11:00:00.000Z',
timezone: null,
},
]);
});
it('nullyfies start date if date is invalid', async () => {
findDatepicker().vm.$emit('input', new Date('2021-08-12'));
await setTimeInputValue('');
expect(wrapper.emitted().input).toHaveLength(3);
expect(wrapper.emitted().input[2]).toEqual([
{
active: true,
cadence: SCAN_CADENCE_OPTIONS[0].value,
startsAt: null,
timezone: null,
},
]);
});
it('emits computed cadence value', async () => {
findCadenceInput().vm.$emit('input', SCAN_CADENCE_OPTIONS[5].value);
await wrapper.vm.$nextTick();
expect(wrapper.emitted().input[1][0].cadence).toEqual({ unit: 'MONTH', duration: 6 });
});
it('deactives schedule when checkbox is unchecked', async () => {
findCheckbox().vm.$emit('input', false);
await wrapper.vm.$nextTick();
expect(wrapper.emitted().input).toHaveLength(2);
expect(wrapper.emitted().input[1]).toEqual([
{
active: false,
cadence: SCAN_CADENCE_OPTIONS[0].value,
startsAt: null,
timezone: null,
},
]);
});
});
describe('editing a schedule', () => {
const startsAt = '2001-09-27T08:45:00.000Z';
beforeEach(() => {
createComponent({
propsData: {
value: {
active: true,
startsAt,
cadence: { unit: 'MONTH', duration: 1 },
timezone: timezoneSST.identifier,
},
},
});
});
it('initializes fields with provided values', () => {
expect(findCheckbox().props('checked')).toBe(true);
expect(findDatepicker().props('value')).toEqual(new Date(startsAt));
expect(findTimeInput().element.value).toBe('08:45');
expect(findCadenceInput().props('value')).toBe(SCAN_CADENCE_OPTIONS[3].value);
});
});
});
......@@ -17,7 +17,8 @@ RSpec.describe Projects::OnDemandScansHelper do
'scanner-profiles-library-path' => project_security_configuration_dast_scans_path(project, anchor: 'scanner-profiles'),
'site-profiles-library-path' => project_security_configuration_dast_scans_path(project, anchor: 'site-profiles'),
'new-scanner-profile-path' => new_project_security_configuration_dast_scans_dast_scanner_profile_path(project),
'new-site-profile-path' => new_project_security_configuration_dast_scans_dast_site_profile_path(project)
'new-site-profile-path' => new_project_security_configuration_dast_scans_dast_site_profile_path(project),
'timezones' => helper.timezone_data(format: :full).to_json
)
end
end
......
......@@ -13477,6 +13477,12 @@ msgstr ""
msgid "Every %{action} attempt has failed: %{job_error_message}. Please try again."
msgstr ""
msgid "Every 3 months"
msgstr ""
msgid "Every 6 months"
msgstr ""
msgid "Every day"
msgstr ""
......@@ -13503,6 +13509,9 @@ msgstr[1] ""
msgid "Every week (%{weekday} at %{time})"
msgstr ""
msgid "Every year"
msgstr ""
msgid "Everyone"
msgstr ""
......@@ -23427,12 +23436,18 @@ msgstr ""
msgid "OnDemandScans|Scanner profile"
msgstr ""
msgid "OnDemandScans|Schedule scan"
msgstr ""
msgid "OnDemandScans|Select one of the existing profiles"
msgstr ""
msgid "OnDemandScans|Site profile"
msgstr ""
msgid "OnDemandScans|Start time"
msgstr ""
msgid "OnDemandScans|Use existing scanner profile"
msgstr ""
......@@ -28080,6 +28095,9 @@ msgstr ""
msgid "Reopens this %{quick_action_target}."
msgstr ""
msgid "Repeats"
msgstr ""
msgid "Replace"
msgstr ""
......
......@@ -69,35 +69,35 @@ describe('date_format_utility.js', () => {
});
});
describe('dateAndTimeToUTCString', () => {
describe('dateAndTimeToISOString', () => {
it('computes the date properly', () => {
expect(utils.dateAndTimeToUTCString(new Date('2021-08-16'), '10:00')).toBe(
expect(utils.dateAndTimeToISOString(new Date('2021-08-16'), '10:00')).toBe(
'2021-08-16T10:00:00.000Z',
);
});
it('computes the date properly with an offset', () => {
expect(utils.dateAndTimeToUTCString(new Date('2021-08-16'), '10:00', '-04:00')).toBe(
'2021-08-16T14:00:00.000Z',
expect(utils.dateAndTimeToISOString(new Date('2021-08-16'), '10:00', '-04:00')).toBe(
'2021-08-16T10:00:00.000-04:00',
);
});
it('throws if date in invalid', () => {
expect(() => utils.dateAndTimeToUTCString('Invalid date', '10:00')).toThrow(
expect(() => utils.dateAndTimeToISOString('Invalid date', '10:00')).toThrow(
'Argument should be a Date instance',
);
});
it('throws if time in invalid', () => {
expect(() => utils.dateAndTimeToUTCString(new Date('2021-08-16'), '')).toThrow(
expect(() => utils.dateAndTimeToISOString(new Date('2021-08-16'), '')).toThrow(
'Invalid time provided',
);
});
it('throws if offset is invalid', () => {
expect(() =>
utils.dateAndTimeToUTCString(new Date('2021-08-16'), '10:00', 'not an offset'),
).toThrow('Invalid time value');
utils.dateAndTimeToISOString(new Date('2021-08-16'), '10:00', 'not an offset'),
).toThrow('Could not initialize date');
});
});
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment