Commit aa7a3a94 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch...

Merge branch '227442-add-frontend-validation-to-restrict-access-by-ip-address-field-in-group-settings-general' into 'master'

Add frontend validation to "Restrict access by IP address" field in Group -> Settings -> General -> Permissions, LFS, 2FA

See merge request gitlab-org/gitlab!39061
parents 95c58772 8255bd8a
import ipaddr from 'ipaddr.js'; import validateIpAddress from 'ee/validators/ip_address';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
const validateAddress = address => {
try {
// Checks if Valid IPv4/IPv6 (CIDR) - Throws if not
return Boolean(ipaddr.parseCIDR(address));
} catch (e) {
// Checks if Valid IPv4/IPv6 (Non-CIDR) - Does not Throw
return ipaddr.isValid(address);
}
};
const validateIP = data => { const validateIP = data => {
let addresses = data.replace(/\s/g, '').split(','); let addresses = data.replace(/\s/g, '').split(',');
addresses = addresses.map(address => validateAddress(address)); addresses = addresses.map(address => validateIpAddress(address));
return !addresses.some(a => !a); return !addresses.some(a => !a);
}; };
......
...@@ -2,7 +2,7 @@ import Vue from 'vue'; ...@@ -2,7 +2,7 @@ import Vue from 'vue';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import CommaSeparatedListTokenSelector from '../components/comma_separated_list_token_selector.vue'; import CommaSeparatedListTokenSelector from '../components/comma_separated_list_token_selector.vue';
export default (el, props = {}, qaSelector) => { export default (el, props = {}, qaSelector, customValidator) => {
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el, el,
...@@ -20,8 +20,14 @@ export default (el, props = {}, qaSelector) => { ...@@ -20,8 +20,14 @@ export default (el, props = {}, qaSelector) => {
regexValidator, regexValidator,
...(regexValidator ? { regexValidator: new RegExp(regexValidator) } : {}), ...(regexValidator ? { regexValidator: new RegExp(regexValidator) } : {}),
...(disallowedValues ? { disallowedValues: JSON.parse(disallowedValues) } : {}), ...(disallowedValues ? { disallowedValues: JSON.parse(disallowedValues) } : {}),
customErrorMessage: '',
}; };
}, },
methods: {
handleTextInput(value) {
this.customErrorMessage = customValidator(value);
},
},
render(createElement) { render(createElement) {
return createElement('comma-separated-list-token-selector', { return createElement('comma-separated-list-token-selector', {
attrs: { attrs: {
...@@ -32,8 +38,10 @@ export default (el, props = {}, qaSelector) => { ...@@ -32,8 +38,10 @@ export default (el, props = {}, qaSelector) => {
ariaLabelledby: this.labelId, ariaLabelledby: this.labelId,
regexValidator: this.regexValidator, regexValidator: this.regexValidator,
disallowedValues: this.disallowedValues, disallowedValues: this.disallowedValues,
customErrorMessage: this.customErrorMessage,
...props, ...props,
}, },
on: customValidator ? { 'text-input': this.handleTextInput } : {},
scopedSlots: { scopedSlots: {
'user-defined-token-content': ({ inputText: value }) => { 'user-defined-token-content': ({ inputText: value }) => {
return sprintf(__('Add "%{value}"'), { value }); return sprintf(__('Add "%{value}"'), { value });
......
import validateIpAddress from 'ee/validators/ip_address';
import { __, sprintf } from '~/locale';
export default address => {
if (!validateIpAddress(address)) {
return sprintf(__('%{address} is an invalid IP address range'), { address });
}
return '';
};
...@@ -30,7 +30,7 @@ export default { ...@@ -30,7 +30,7 @@ export default {
required: false, required: false,
default: () => [], default: () => [],
}, },
errorMessage: { regexErrorMessage: {
type: String, type: String,
required: false, required: false,
default: '', default: '',
...@@ -40,6 +40,11 @@ export default { ...@@ -40,6 +40,11 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
customErrorMessage: {
type: String,
required: false,
default: '',
},
}, },
data() { data() {
return { return {
...@@ -54,14 +59,14 @@ export default { ...@@ -54,14 +59,14 @@ export default {
}, },
computedErrorMessage() { computedErrorMessage() {
if (this.regexValidator !== null && this.textInputValue.match(this.regexValidator) === null) { if (this.regexValidator !== null && this.textInputValue.match(this.regexValidator) === null) {
return this.errorMessage; return this.regexErrorMessage;
} }
if (!isEmpty(this.disallowedValues) && this.disallowedValues.includes(this.textInputValue)) { if (!isEmpty(this.disallowedValues) && this.disallowedValues.includes(this.textInputValue)) {
return this.disallowedValueErrorMessage; return this.disallowedValueErrorMessage;
} }
return ''; return this.customErrorMessage;
}, },
}, },
watch: { watch: {
...@@ -111,6 +116,7 @@ export default { ...@@ -111,6 +116,7 @@ export default {
handleTextInput(value) { handleTextInput(value) {
this.hideErrorMessage = true; this.hideErrorMessage = true;
this.textInputValue = value; this.textInputValue = value;
this.$emit('text-input', value);
}, },
handleBlur() { handleBlur() {
this.hideErrorMessage = true; this.hideErrorMessage = true;
......
import '~/pages/groups/edit'; import '~/pages/groups/edit';
import initAccessRestrictionField from 'ee/groups/settings/access_restriction_field'; import initAccessRestrictionField from 'ee/groups/settings/access_restriction_field';
import validateRestrictedIpAddress from 'ee/groups/settings/access_restriction_field/validate_ip_address';
import { __ } from '~/locale'; import { __ } from '~/locale';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initAccessRestrictionField('.js-allowed-email-domains', { initAccessRestrictionField('.js-allowed-email-domains', {
placeholder: __('Enter domain'), placeholder: __('Enter domain'),
errorMessage: __('The domain you entered is misformatted.'), regexErrorMessage: __('The domain you entered is misformatted.'),
disallowedValueErrorMessage: __('The domain you entered is not allowed.'), disallowedValueErrorMessage: __('The domain you entered is not allowed.'),
}); });
initAccessRestrictionField( initAccessRestrictionField(
'.js-ip-restriction', '.js-ip-restriction',
{ placeholder: __('Enter IP address range') }, { placeholder: __('Enter IP address range') },
'ip_restriction_field', 'ip_restriction_field',
validateRestrictedIpAddress,
); );
}); });
import ipaddr from 'ipaddr.js';
export default address => {
// Reject IP addresses that are only integers to match Ruby IPAddr
// https://github.com/whitequark/ipaddr.js/issues/7#issuecomment-158545695
if (/^\d+$/.exec(address)) {
return false;
}
try {
// Checks if Valid IPv4/IPv6 (CIDR) - Throws if not
return Boolean(ipaddr.parseCIDR(address));
} catch (e) {
// Checks if Valid IPv4/IPv6 (Non-CIDR) - Does not Throw
return ipaddr.isValid(address);
}
};
---
title: Add frontend validation to "Restrict access by IP address" field
merge_request: 39061
author:
type: changed
import ipaddr from 'ipaddr.js';
import validateRestrictedIpAddress from 'ee/groups/settings/access_restriction_field/validate_ip_address';
describe('validateRestrictedIpAddress', () => {
describe('when IP address is not valid', () => {
it('returns an error message', () => {
ipaddr.isValid = jest.fn(() => false);
const result = validateRestrictedIpAddress('foo bar');
expect(ipaddr.isValid).toHaveBeenCalledWith('foo bar');
expect(result).toBe(`foo bar is an invalid IP address range`);
});
});
describe('when IP address is valid', () => {
it('returns an empty string', () => {
ipaddr.isValid = jest.fn(() => true);
const result = validateRestrictedIpAddress('192.168.0.0');
expect(ipaddr.isValid).toHaveBeenCalledWith('192.168.0.0');
expect(result).toBe('');
});
});
});
...@@ -12,13 +12,16 @@ describe('CommaSeparatedListTokenSelector', () => { ...@@ -12,13 +12,16 @@ describe('CommaSeparatedListTokenSelector', () => {
const defaultProps = { const defaultProps = {
hiddenInputId: 'comma-separated-list', hiddenInputId: 'comma-separated-list',
ariaLabelledby: 'comma-separated-list-label', ariaLabelledby: 'comma-separated-list-label',
errorMessage: 'The value entered is invalid', regexErrorMessage: 'The value entered is invalid',
disallowedValueErrorMessage: 'The value entered is not allowed', disallowedValueErrorMessage: 'The value entered is not allowed',
}; };
const createComponent = options => { const createComponent = options => {
wrapper = mount(CommaSeparatedListTokenSelector, { wrapper = mount(CommaSeparatedListTokenSelector, {
attachTo: div, attachTo: div,
scopedSlots: {
'user-defined-token-content': '<span>Add "{{props.inputText}}"</span>',
},
...options, ...options,
propsData: { propsData: {
...defaultProps, ...defaultProps,
...@@ -132,6 +135,16 @@ describe('CommaSeparatedListTokenSelector', () => { ...@@ -132,6 +135,16 @@ describe('CommaSeparatedListTokenSelector', () => {
}); });
}); });
describe('when text input is typed in', () => {
it('emits `text-input` event', async () => {
createComponent();
await setTokenSelectorInputValue('foo bar');
expect(wrapper.emitted('text-input')[0]).toEqual(['foo bar']);
});
});
describe('when enter key is pressed', () => { describe('when enter key is pressed', () => {
it('does not submit the form if token selector text input has a value', async () => { it('does not submit the form if token selector text input has a value', async () => {
createComponent(); createComponent();
...@@ -145,7 +158,7 @@ describe('CommaSeparatedListTokenSelector', () => { ...@@ -145,7 +158,7 @@ describe('CommaSeparatedListTokenSelector', () => {
}); });
describe('when `regexValidator` prop is set', () => { describe('when `regexValidator` prop is set', () => {
it('displays `errorMessage` if regex fails', async () => { it('displays `regexErrorMessage` if regex fails', async () => {
createComponent({ createComponent({
propsData: { propsData: {
regexValidator: /baz/, regexValidator: /baz/,
...@@ -176,8 +189,22 @@ describe('CommaSeparatedListTokenSelector', () => { ...@@ -176,8 +189,22 @@ describe('CommaSeparatedListTokenSelector', () => {
}); });
}); });
describe('when `regexValidator` and `disallowedValues` props are set', () => { describe('when `customErrorMessage` prop is set', () => {
it('displays `errorMessage` if regex fails', async () => { it('displays `customErrorMessage`', () => {
createComponent({
propsData: {
customErrorMessage: 'Value is invalid',
},
});
tokenSelectorTriggerEnter();
expect(findErrorMessageText()).toBe('Value is invalid');
});
});
describe('when `regexValidator`, `disallowedValues` and `customErrorMessage` props are set', () => {
it('displays `regexErrorMessage` if regex fails', async () => {
createComponent({ createComponent({
propsData: { propsData: {
regexValidator: /baz/, regexValidator: /baz/,
...@@ -206,6 +233,22 @@ describe('CommaSeparatedListTokenSelector', () => { ...@@ -206,6 +233,22 @@ describe('CommaSeparatedListTokenSelector', () => {
expect(findErrorMessageText()).toBe('The value entered is not allowed'); expect(findErrorMessageText()).toBe('The value entered is not allowed');
}); });
it('displays `customErrorMessage` if regex passes and value is not in the disallowed list', async () => {
createComponent({
propsData: {
regexValidator: /foo bar/,
disallowedValues: ['foo', 'bar', 'baz'],
customErrorMessage: 'Value is invalid',
},
});
await setTokenSelectorInputValue('foo bar');
tokenSelectorTriggerEnter();
expect(findErrorMessageText()).toBe('Value is invalid');
});
}); });
}); });
...@@ -216,38 +259,21 @@ describe('CommaSeparatedListTokenSelector', () => { ...@@ -216,38 +259,21 @@ describe('CommaSeparatedListTokenSelector', () => {
regexValidator: /foo/, regexValidator: /foo/,
disallowedValues: ['bar', 'baz'], disallowedValues: ['bar', 'baz'],
}, },
scopedSlots: {
'user-defined-token-content': '<span>Add "{{props.inputText}}"</span>',
},
}); });
await setTokenSelectorInputValue('foo'); await setTokenSelectorInputValue('foo');
tokenSelectorTriggerEnter(); expect(findTokenSelectorDropdown().text()).toBe('Add "foo"');
expect(
findTokenSelector()
.find('[role="menuitem"]')
.text(),
).toBe('Add "foo"');
}); });
}); });
describe('when `regexValidator` and `disallowedValues` props are not set', () => { describe('when `regexValidator`, `disallowedValues` and `customErrorMessage` props are not set', () => {
it('allows any value to be added', async () => { it('allows any value to be added', async () => {
createComponent({ createComponent();
scopedSlots: {
'user-defined-token-content': '<span>Add "{{props.inputText}}"</span>',
},
});
await setTokenSelectorInputValue('foo'); await setTokenSelectorInputValue('foo');
expect( expect(findTokenSelectorDropdown().text()).toBe('Add "foo"');
findTokenSelector()
.find('[role="menuitem"]')
.text(),
).toBe('Add "foo"');
}); });
}); });
......
import ipaddr from 'ipaddr.js';
import validateIpAddress from 'ee/validators/ip_address';
describe('validateIpAddress', () => {
describe('when IP address is only integers', () => {
it.each`
address
${1}
${19}
${192}
`('$address - returns false', ({ address }) => {
expect(validateIpAddress(address)).toBe(false);
});
});
describe('when IP address is in valid CIDR format', () => {
it('returns true', () => {
ipaddr.parseCIDR = jest.fn(() => [
{
octets: [192, 168, 0, 0],
},
24,
]);
const result = validateIpAddress('192.168.0.0/24');
expect(ipaddr.parseCIDR).toHaveBeenCalledWith('192.168.0.0/24');
expect(result).toBe(true);
});
});
describe('when IP address is not in valid CIDR format', () => {
it('calls `ipaddr.isValid`', () => {
ipaddr.parseCIDR = jest.fn(() => {
throw new Error();
});
ipaddr.isValid = jest.fn();
validateIpAddress('192.168.0.0');
expect(ipaddr.isValid).toHaveBeenCalledWith('192.168.0.0');
});
});
});
...@@ -297,6 +297,9 @@ msgstr[1] "" ...@@ -297,6 +297,9 @@ msgstr[1] ""
msgid "%{actionText} & %{openOrClose} %{noteable}" msgid "%{actionText} & %{openOrClose} %{noteable}"
msgstr "" msgstr ""
msgid "%{address} is an invalid IP address range"
msgstr ""
msgid "%{author_link} wrote:" msgid "%{author_link} wrote:"
msgstr "" msgstr ""
......
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