diff --git a/karbor/api/schemas/__init__.py b/karbor/api/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/karbor/api/schemas/plans.py b/karbor/api/schemas/plans.py new file mode 100644 index 00000000..d8a0088b --- /dev/null +++ b/karbor/api/schemas/plans.py @@ -0,0 +1,59 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Schema for Karbor V1 Plans API. + +""" + +from karbor.api.validation import parameter_types + + +create = { + 'type': 'object', + 'properties': { + 'type': 'object', + 'plan': { + 'type': 'object', + 'properties': { + 'name': parameter_types.name, + 'description': parameter_types.description, + 'provider_id': parameter_types.uuid, + 'parameters': parameter_types.parameters, + 'resources': parameter_types.resources, + }, + 'required': ['provider_id', 'parameters'], + 'additionalProperties': False, + }, + }, + 'required': ['plan'], + 'additionalProperties': False, +} + +update = { + 'type': 'object', + 'properties': { + 'type': 'object', + 'plan': { + 'type': 'object', + 'properties': { + 'name': parameter_types.name, + 'status': {'type': ['string', 'null']}, + 'resources': parameter_types.resources, + }, + 'required': [], + 'additionalProperties': False, + }, + }, + 'required': ['plan'], + 'additionalProperties': False, +} diff --git a/karbor/api/v1/plans.py b/karbor/api/v1/plans.py index b38efd34..983805e3 100644 --- a/karbor/api/v1/plans.py +++ b/karbor/api/v1/plans.py @@ -22,6 +22,8 @@ from webob import exc from karbor.api import common from karbor.api.openstack import wsgi +from karbor.api.schemas import plans as plan_schema +from karbor.api import validation from karbor.common import constants from karbor import exception from karbor.i18n import _ @@ -248,6 +250,7 @@ class PlansController(wsgi.Controller): """Return plan search options allowed by non-admin.""" return CONF.query_plan_filters + @validation.schema(plan_schema.create) def create(self, req, body): """Creates a new plan.""" if not self.is_valid_body(body, 'plan'): @@ -319,6 +322,7 @@ class PlansController(wsgi.Controller): return retval + @validation.schema(plan_schema.update) def update(self, req, id, body): """Update a plan.""" context = req.environ['karbor.context'] diff --git a/karbor/api/validation/__init__.py b/karbor/api/validation/__init__.py new file mode 100644 index 00000000..223d6185 --- /dev/null +++ b/karbor/api/validation/__init__.py @@ -0,0 +1,43 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Request Body validating middleware. + +""" + +import functools + +from karbor.api.validation import validators + + +def schema(request_body_schema): + """Register a schema to validate request body. + + Registered schema will be used for validating request body just before + API method executing. + + :param dict request_body_schema: a schema to validate request body + + """ + + def add_validator(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + schema_validator = validators._SchemaValidator( + request_body_schema) + schema_validator.validate(kwargs['body']) + + return func(*args, **kwargs) + return wrapper + + return add_validator diff --git a/karbor/api/validation/parameter_types.py b/karbor/api/validation/parameter_types.py new file mode 100644 index 00000000..732d6915 --- /dev/null +++ b/karbor/api/validation/parameter_types.py @@ -0,0 +1,165 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Common parameter types for validating request Body. + +""" + +import re +import unicodedata + +import six + + +def _is_printable(char): + """determine if a unicode code point is printable. + + This checks if the character is either "other" (mostly control + codes), or a non-horizontal space. All characters that don't match + those criteria are considered printable; that is: letters; + combining marks; numbers; punctuation; symbols; (horizontal) space + separators. + """ + category = unicodedata.category(char) + return (not category.startswith("C") and + (not category.startswith("Z") or category == "Zs")) + + +def _get_all_chars(): + for i in range(0xFFFF): + yield six.unichr(i) + + +# build a regex that matches all printable characters. This allows +# spaces in the middle of the name. Also note that the regexp below +# deliberately allows the empty string. This is so only the constraint +# which enforces a minimum length for the name is triggered when an +# empty string is tested. Otherwise it is not deterministic which +# constraint fails and this causes issues for some unittests when +# PYTHONHASHSEED is set randomly. + +def _build_regex_range(ws=True, invert=False, exclude=None): + """Build a range regex for a set of characters in utf8. + + This builds a valid range regex for characters in utf8 by + iterating the entire space and building up a set of x-y ranges for + all the characters we find which are valid. + + :param ws: should we include whitespace in this range. + :param exclude: any characters we want to exclude + :param invert: invert the logic + + The inversion is useful when we want to generate a set of ranges + which is everything that's not a certain class. For instance, + produce all all the non printable characters as a set of ranges. + """ + if exclude is None: + exclude = [] + regex = "" + # are we currently in a range + in_range = False + # last character we found, for closing ranges + last = None + # last character we added to the regex, this lets us know that we + # already have B in the range, which means we don't need to close + # it out with B-B. While the later seems to work, it's kind of bad form. + last_added = None + + def valid_char(char): + if char in exclude: + result = False + elif ws: + result = _is_printable(char) + else: + # Zs is the unicode class for space characters, of which + # there are about 10 in this range. + result = (_is_printable(char) and + unicodedata.category(char) != "Zs") + if invert is True: + return not result + return result + + # iterate through the entire character range. in_ + for c in _get_all_chars(): + if valid_char(c): + if not in_range: + regex += re.escape(c) + last_added = c + in_range = True + else: + if in_range and last != last_added: + regex += "-" + re.escape(last) + in_range = False + last = c + else: + if in_range: + regex += "-" + re.escape(c) + return regex + + +valid_description_regex_base = '^[%s]*$' + +valid_description_regex = valid_description_regex_base % ( + _build_regex_range()) + + +name = { + 'type': ['string', 'null'], 'minLength': 0, 'maxLength': 255, + 'format': 'name' +} + + +description = { + 'type': ['string', 'null'], 'minLength': 0, 'maxLength': 255, + 'pattern': valid_description_regex, +} + + +boolean = { + 'type': ['boolean', 'string'], + 'enum': [True, 'True', 'TRUE', 'true', '1', 'ON', 'On', 'on', + 'YES', 'Yes', 'yes', 'y', 't', + False, 'False', 'FALSE', 'false', '0', 'OFF', 'Off', 'off', + 'NO', 'No', 'no', 'n', 'f'], +} + + +uuid = { + 'type': 'string', 'format': 'uuid' +} + + +metadata = { + 'type': ['object', 'null'], + 'patternProperties': { + '^[a-zA-Z0-9-_:.# ]{1,255}$': { + 'type': ['boolean', 'string'] + } + }, + 'additionalProperties': False +} + +parameters = { + 'type': 'object', + 'patternProperties': { + '^[a-zA-Z0-9-_:.# ]{1,255}$': metadata + }, + 'additionalProperties': False +} + +resources = { + 'type': 'array', + 'items': { + 'type': 'object' + } +} diff --git a/karbor/api/validation/validators.py b/karbor/api/validation/validators.py new file mode 100644 index 00000000..18e9e4dc --- /dev/null +++ b/karbor/api/validation/validators.py @@ -0,0 +1,214 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Internal implementation of request Body validating middleware. + +""" + +import re + +import jsonschema +from jsonschema import exceptions as jsonschema_exc +from oslo_utils import timeutils +from oslo_utils import uuidutils +import six + +from karbor import exception +from karbor.i18n import _ + + +def _soft_validate_additional_properties( + validator, additional_properties_value, param_value, schema): + """Validator function. + + If there are not any properties on the param_value that are not specified + in the schema, this will return without any effect. If there are any such + extra properties, they will be handled as follows: + + - if the validator passed to the method is not of type "object", this + method will return without any effect. + - if the 'additional_properties_value' parameter is True, this method will + return without any effect. + - if the schema has an additionalProperties value of True, the extra + properties on the param_value will not be touched. + - if the schema has an additionalProperties value of False and there + aren't patternProperties specified, the extra properties will be stripped + from the param_value. + - if the schema has an additionalProperties value of False and there + are patternProperties specified, the extra properties will not be + touched and raise validation error if pattern doesn't match. + """ + if (not validator.is_type(param_value, "object") or + additional_properties_value): + return + + properties = schema.get("properties", {}) + patterns = "|".join(schema.get("patternProperties", {})) + extra_properties = set() + for prop in param_value: + if prop not in properties: + if patterns: + if not re.search(patterns, prop): + extra_properties.add(prop) + else: + extra_properties.add(prop) + + if not extra_properties: + return + + if patterns: + error = "Additional properties are not allowed (%s %s unexpected)" + if len(extra_properties) == 1: + verb = "was" + else: + verb = "were" + yield jsonschema_exc.ValidationError( + error % (", ".join(repr(extra) for extra in extra_properties), + verb)) + else: + for prop in extra_properties: + del param_value[prop] + + +@jsonschema.FormatChecker.cls_checks('date-time') +def _validate_datetime_format(param_value): + try: + timeutils.parse_isotime(param_value) + except ValueError: + return False + else: + return True + + +@jsonschema.FormatChecker.cls_checks('name', exception.InvalidName) +def _validate_name(param_value): + if not param_value: + msg = "The 'name' can not be None." + raise exception.InvalidName(reason=msg) + elif len(param_value.strip()) == 0: + msg = "The 'name' can not be empty." + raise exception.InvalidName(reason=msg) + return True + + +@jsonschema.FormatChecker.cls_checks('uuid') +def _validate_uuid_format(instance): + return uuidutils.is_uuid_like(instance) + + +class FormatChecker(jsonschema.FormatChecker): + """A FormatChecker can output the message from cause exception + + We need understandable validation errors messages for users. When a + custom checker has an exception, the FormatChecker will output a + readable message provided by the checker. + """ + + def check(self, param_value, format): + """Check whether the param_value conforms to the given format. + + :argument param_value: the param_value to check + :type: any primitive type (str, number, bool) + :argument str format: the format that param_value should conform to + :raises: :exc:`FormatError` if param_value does not conform to format + """ + + if format not in self.checkers: + return + + # For safety reasons custom checkers can be registered with + # allowed exception types. Anything else will fall into the + # default formatter. + func, raises = self.checkers[format] + result, cause = None, None + + try: + result = func(param_value) + except raises as e: + cause = e + if not result: + msg = "%r is not a %r" % (param_value, format) + raise jsonschema_exc.FormatError(msg, cause=cause) + + +class _SchemaValidator(object): + """A validator class + + This class is changed from Draft4Validator to validate minimum/maximum + value of a string number(e.g. '10'). This changes can be removed when + we tighten up the API definition and the XML conversion. + Also FormatCheckers are added for checking data formats which would be + passed through cinder api commonly. + + """ + validator = None + validator_org = jsonschema.Draft4Validator + + def __init__(self, schema, relax_additional_properties=False): + validators = { + 'minimum': self._validate_minimum, + 'maximum': self._validate_maximum, + } + if relax_additional_properties: + validators[ + 'additionalProperties'] = _soft_validate_additional_properties + + validator_cls = jsonschema.validators.extend(self.validator_org, + validators) + format_checker = FormatChecker() + self.validator = validator_cls(schema, format_checker=format_checker) + + def validate(self, *args, **kwargs): + try: + self.validator.validate(*args, **kwargs) + except jsonschema.ValidationError as ex: + if isinstance(ex.cause, exception.InvalidName): + detail = ex.cause.msg + elif len(ex.path) > 0: + detail = _("Invalid input for field/attribute %(path)s." + " Value: %(value)s. %(message)s") % { + 'path': ex.path.pop(), 'value': ex.instance, + 'message': ex.message + } + else: + detail = ex.message + raise exception.ValidationError(detail=detail) + except TypeError as ex: + # NOTE: If passing non string value to patternProperties parameter, + # TypeError happens. Here is for catching the TypeError. + detail = six.text_type(ex) + raise exception.ValidationError(detail=detail) + + def _number_from_str(self, param_value): + try: + value = int(param_value) + except (ValueError, TypeError): + try: + value = float(param_value) + except (ValueError, TypeError): + return None + return value + + def _validate_minimum(self, validator, minimum, param_value, schema): + param_value = self._number_from_str(param_value) + if param_value is None: + return + return self.validator_org.VALIDATORS['minimum'](validator, minimum, + param_value, schema) + + def _validate_maximum(self, validator, maximum, param_value, schema): + param_value = self._number_from_str(param_value) + if param_value is None: + return + return self.validator_org.VALIDATORS['maximum'](validator, maximum, + param_value, schema) diff --git a/karbor/exception.py b/karbor/exception.py index 76de7246..18cff957 100644 --- a/karbor/exception.py +++ b/karbor/exception.py @@ -445,3 +445,11 @@ class PlanLimitExceeded(QuotaError): class UnexpectedOverQuota(QuotaError): message = _("Unexpected over quota on %(name)s.") + + +class InvalidName(Invalid): + message = _("An invalid 'name' value was provided. %(reason)s") + + +class ValidationError(Invalid): + message = "%(detail)s" diff --git a/karbor/tests/unit/api/test_api_validation.py b/karbor/tests/unit/api/test_api_validation.py new file mode 100644 index 00000000..1821441c --- /dev/null +++ b/karbor/tests/unit/api/test_api_validation.py @@ -0,0 +1,500 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import re +import sys + +import fixtures +import six +from six.moves import http_client as http + +from karbor.api import validation +from karbor.api.validation import parameter_types +from karbor import exception +from karbor.tests import base + + +class FakeRequest(object): + environ = {} + + +class ValidationRegex(base.TestCase): + + def test_build_regex_range(self): + + def _get_all_chars(): + for i in range(0x7F): + yield six.unichr(i) + + self.useFixture(fixtures.MonkeyPatch( + 'karbor.api.validation.parameter_types._get_all_chars', + _get_all_chars)) + + r = parameter_types._build_regex_range(ws=False) + self.assertEqual(re.escape('!') + '-' + re.escape('~'), r) + + # if we allow whitespace the range starts earlier + r = parameter_types._build_regex_range(ws=True) + self.assertEqual(re.escape(' ') + '-' + re.escape('~'), r) + + # excluding a character will give us 2 ranges + r = parameter_types._build_regex_range(ws=True, exclude=['A']) + self.assertEqual(re.escape(' ') + '-' + re.escape('@') + + 'B' + '-' + re.escape('~'), r) + + # inverting which gives us all the initial unprintable characters. + r = parameter_types._build_regex_range(ws=False, invert=True) + self.assertEqual(re.escape('\x00') + '-' + re.escape(' '), r) + + # excluding characters that create a singleton. Naively this would be: + # ' -@B-BD-~' which seems to work, but ' -@BD-~' is more natural. + r = parameter_types._build_regex_range(ws=True, exclude=['A', 'C']) + self.assertEqual(re.escape(' ') + '-' + re.escape('@') + + 'B' + 'D' + '-' + re.escape('~'), r) + + # ws=True means the positive regex has printable whitespaces, + # so the inverse will not. The inverse will include things we + # exclude. + r = parameter_types._build_regex_range( + ws=True, exclude=['A', 'B', 'C', 'Z'], invert=True) + self.assertEqual(re.escape('\x00') + '-' + re.escape('\x1f') + + 'A-CZ', r) + + +class APIValidationTestCase(base.TestCase): + + def setUp(self, schema=None): + super(APIValidationTestCase, self).setUp() + self.post = None + + if schema is not None: + @validation.schema(request_body_schema=schema) + def post(req, body): + return 'Validation succeeded.' + + self.post = post + + def check_validation_error(self, method, body, expected_detail, req=None): + if not req: + req = FakeRequest() + try: + method(body=body, req=req,) + except exception.ValidationError as ex: + self.assertEqual(http.BAD_REQUEST, ex.kwargs['code']) + if isinstance(expected_detail, list): + self.assertIn(ex.kwargs['detail'], expected_detail, + 'Exception details did not match expected') + elif not re.match(expected_detail, ex.kwargs['detail']): + self.assertEqual(expected_detail, ex.kwargs['detail'], + 'Exception details did not match expected') + except Exception as ex: + self.fail('An unexpected exception happens: %s' % ex) + else: + self.fail('Any exception did not happen.') + + +class RequiredDisableTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'integer', + }, + }, + } + super(RequiredDisableTestCase, self).setUp(schema=schema) + + def test_validate_required_disable(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 1}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'abc': 1}, req=FakeRequest())) + + +class RequiredEnableTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'integer', + }, + }, + 'required': ['foo'] + } + super(RequiredEnableTestCase, self).setUp(schema=schema) + + def test_validate_required_enable(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 1}, req=FakeRequest())) + + def test_validate_required_enable_fails(self): + detail = "'foo' is a required property" + self.check_validation_error(self.post, body={'abc': 1}, + expected_detail=detail) + + +class AdditionalPropertiesEnableTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'integer', + }, + }, + 'required': ['foo'], + } + super(AdditionalPropertiesEnableTestCase, self).setUp(schema=schema) + + def test_validate_additionalProperties_enable(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 1}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 1, 'ext': 1}, + req=FakeRequest())) + + +class AdditionalPropertiesDisableTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'integer', + }, + }, + 'required': ['foo'], + 'additionalProperties': False, + } + super(AdditionalPropertiesDisableTestCase, self).setUp(schema=schema) + + def test_validate_additionalProperties_disable(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 1}, req=FakeRequest())) + + def test_validate_additionalProperties_disable_fails(self): + detail = "Additional properties are not allowed ('ext' was unexpected)" + self.check_validation_error(self.post, body={'foo': 1, 'ext': 1}, + expected_detail=detail) + + +class PatternPropertiesTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'patternProperties': { + '^[a-zA-Z0-9]{1,10}$': { + 'type': 'string' + }, + }, + 'additionalProperties': False, + } + super(PatternPropertiesTestCase, self).setUp(schema=schema) + + def test_validate_patternProperties(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 'bar'}, req=FakeRequest())) + + def test_validate_patternProperties_fails(self): + details = [ + "Additional properties are not allowed ('__' was unexpected)", + "'__' does not match any of the regexes: '^[a-zA-Z0-9]{1,10}$'" + ] + self.check_validation_error(self.post, body={'__': 'bar'}, + expected_detail=details) + + details = [ + "'' does not match any of the regexes: '^[a-zA-Z0-9]{1,10}$'", + "Additional properties are not allowed ('' was unexpected)" + ] + self.check_validation_error(self.post, body={'': 'bar'}, + expected_detail=details) + + details = [ + ("'0123456789a' does not match any of the regexes: " + "'^[a-zA-Z0-9]{1,10}$'"), + ("Additional properties are not allowed ('0123456789a' was" + " unexpected)") + ] + self.check_validation_error(self.post, body={'0123456789a': 'bar'}, + expected_detail=details) + + if sys.version[:3] == '3.5': + detail = "expected string or bytes-like object" + else: + detail = "expected string or buffer" + self.check_validation_error(self.post, body={None: 'bar'}, + expected_detail=detail) + + +class StringTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'string', + }, + }, + } + super(StringTestCase, self).setUp(schema=schema) + + def test_validate_string(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 'abc'}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': '0'}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': ''}, req=FakeRequest())) + + def test_validate_string_fails(self): + detail = ("Invalid input for field/attribute foo. Value: 1." + " 1 is not of type 'string'") + self.check_validation_error(self.post, body={'foo': 1}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: 1.5." + " 1.5 is not of type 'string'") + self.check_validation_error(self.post, body={'foo': 1.5}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: True." + " True is not of type 'string'") + self.check_validation_error(self.post, body={'foo': True}, + expected_detail=detail) + + +class StringLengthTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'string', + 'minLength': 1, + 'maxLength': 10, + }, + }, + } + super(StringLengthTestCase, self).setUp(schema=schema) + + def test_validate_string_length(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': '0'}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': '0123456789'}, + req=FakeRequest())) + + def test_validate_string_length_fails(self): + detail = ("Invalid input for field/attribute foo. Value: ." + " '' is too short") + self.check_validation_error(self.post, body={'foo': ''}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: 0123456789a." + " '0123456789a' is too long") + self.check_validation_error(self.post, body={'foo': '0123456789a'}, + expected_detail=detail) + + +class IntegerTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': { + 'type': ['integer', 'string'], + 'pattern': '^[0-9]+$', + }, + }, + } + super(IntegerTestCase, self).setUp(schema=schema) + + def test_validate_integer(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 1}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': '1'}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': '0123456789'}, + req=FakeRequest())) + + def test_validate_integer_fails(self): + detail = ("Invalid input for field/attribute foo. Value: abc." + " 'abc' does not match '^[0-9]+$'") + self.check_validation_error(self.post, body={'foo': 'abc'}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: True." + " True is not of type 'integer', 'string'") + self.check_validation_error(self.post, body={'foo': True}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: 0xffff." + " '0xffff' does not match '^[0-9]+$'") + self.check_validation_error(self.post, body={'foo': '0xffff'}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: 1.0." + " 1.0 is not of type 'integer', 'string'") + self.check_validation_error(self.post, body={'foo': 1.0}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: 1.0." + " '1.0' does not match '^[0-9]+$'") + self.check_validation_error(self.post, body={'foo': '1.0'}, + expected_detail=detail) + + +class IntegerRangeTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': { + 'type': ['integer', 'string'], + 'pattern': '^[0-9]+$', + 'minimum': 1, + 'maximum': 10, + }, + }, + } + super(IntegerRangeTestCase, self).setUp(schema=schema) + + def test_validate_integer_range(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 1}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 10}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': '1'}, req=FakeRequest())) + + def test_validate_integer_range_fails(self): + detail = ("Invalid input for field/attribute foo. Value: 0." + " 0(.0)? is less than the minimum of 1") + self.check_validation_error(self.post, body={'foo': 0}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: 11." + " 11(.0)? is greater than the maximum of 10") + self.check_validation_error(self.post, body={'foo': 11}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: 0." + " 0(.0)? is less than the minimum of 1") + self.check_validation_error(self.post, body={'foo': '0'}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: 11." + " 11(.0)? is greater than the maximum of 10") + self.check_validation_error(self.post, body={'foo': '11'}, + expected_detail=detail) + + +class BooleanTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': parameter_types.boolean, + }, + } + super(BooleanTestCase, self).setUp(schema=schema) + + def test_validate_boolean(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': True}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': False}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 'True'}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 'False'}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': '1'}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': '0'}, req=FakeRequest())) + + def test_validate_boolean_fails(self): + enum_boolean = ("[True, 'True', 'TRUE', 'true', '1', 'ON', 'On'," + " 'on', 'YES', 'Yes', 'yes', 'y', 't'," + " False, 'False', 'FALSE', 'false', '0', 'OFF', 'Off'," + " 'off', 'NO', 'No', 'no', 'n', 'f']") + + detail = ("Invalid input for field/attribute foo. Value: bar." + " 'bar' is not one of %s") % enum_boolean + self.check_validation_error(self.post, body={'foo': 'bar'}, + expected_detail=detail) + + detail = ("Invalid input for field/attribute foo. Value: 2." + " '2' is not one of %s") % enum_boolean + self.check_validation_error(self.post, body={'foo': '2'}, + expected_detail=detail) + + +class NameTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': parameter_types.name, + }, + } + super(NameTestCase, self).setUp(schema=schema) + + def test_validate_name(self): + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 'volume.1'}, + req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 'volume 1'}, + req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': 'a'}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': u'\u0434'}, req=FakeRequest())) + self.assertEqual('Validation succeeded.', + self.post(body={'foo': u'\u0434\u2006\ufffd'}, + req=FakeRequest())) + + +class DatetimeTestCase(APIValidationTestCase): + + def setUp(self): + schema = { + 'type': 'object', + 'properties': { + 'foo': { + 'type': 'string', + 'format': 'date-time', + }, + }, + } + super(DatetimeTestCase, self).setUp(schema=schema) + + def test_validate_datetime(self): + self.assertEqual('Validation succeeded.', + self.post(body={ + 'foo': '2017-01-14T01:00:00Z'}, req=FakeRequest() + )) diff --git a/karbor/tests/unit/api/v1/test_plans.py b/karbor/tests/unit/api/v1/test_plans.py index 36e57b3d..576fe7cd 100644 --- a/karbor/tests/unit/api/v1/test_plans.py +++ b/karbor/tests/unit/api/v1/test_plans.py @@ -49,41 +49,37 @@ class PlanApiTest(base.TestCase): body = {"plan": plan} req = fakes.HTTPRequest.blank('/v1/plans') mock_provider.return_value = fakes.PROVIDER_OS - self.controller.create(req, body) + self.controller.create(req, body=body) self.assertTrue(mock_plan_create.called) def test_plan_create_InvalidBody(self): plan = self._plan_in_request_body() body = {"planxx": plan} req = fakes.HTTPRequest.blank('/v1/plans') - self.assertRaises(exc.HTTPUnprocessableEntity, self.controller.create, - req, body) + self.assertRaises(exception.ValidationError, self.controller.create, + req, body=body) def test_plan_create_InvalidProviderId(self): plan = self._plan_in_request_body( name=DEFAULT_NAME, description=DEFAULT_DESCRIPTION, provider_id="", - status=constants.PLAN_STATUS_SUSPENDED, - project_id=DEFAULT_PROJECT_ID, resources=[]) body = {"plan": plan} req = fakes.HTTPRequest.blank('/v1/plans') - self.assertRaises(exception.InvalidInput, self.controller.create, - req, body) + self.assertRaises(exception.ValidationError, self.controller.create, + req, body=body) def test_plan_create_InvalidResources(self): plan = self._plan_in_request_body( name=DEFAULT_NAME, description=DEFAULT_DESCRIPTION, provider_id=DEFAULT_PROVIDER_ID, - status=constants.PLAN_STATUS_SUSPENDED, - project_id=DEFAULT_PROJECT_ID, resources=[]) body = {"plan": plan} req = fakes.HTTPRequest.blank('/v1/plans') self.assertRaises(exception.InvalidInput, self.controller.create, - req, body) + req, body=body) @mock.patch( 'karbor.services.protection.rpcapi.ProtectionAPI.show_provider') @@ -93,57 +89,51 @@ class PlanApiTest(base.TestCase): name=DEFAULT_NAME, description=DEFAULT_DESCRIPTION, provider_id=DEFAULT_PROVIDER_ID, - status=constants.PLAN_STATUS_SUSPENDED, - project_id=DEFAULT_PROJECT_ID, parameters=parameters) body = {"plan": plan} mock_provider.return_value = fakes.PROVIDER_OS req = fakes.HTTPRequest.blank('/v1/plans') self.assertRaises(exc.HTTPBadRequest, self.controller.create, - req, body) + req, body=body) @mock.patch( 'karbor.api.v1.plans.PlansController._plan_get') @mock.patch( 'karbor.api.v1.plans.PlansController._plan_update') def test_plan_update(self, mock_plan_update, mock_plan_get): - plan = self._plan_in_request_body() + plan = self._plan_update_request_body() body = {"plan": plan} req = fakes.HTTPRequest.blank('/v1/plans') self.controller.update( - req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body) + req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body=body) self.assertTrue(mock_plan_update.called) self.assertTrue(mock_plan_get.called) def test_plan_update_InvalidBody(self): - plan = self._plan_in_request_body() + plan = self._plan_update_request_body() body = {"planxx": plan} req = fakes.HTTPRequest.blank('/v1/plans') self.assertRaises( - exc.HTTPBadRequest, self.controller.update, - req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body) + exception.ValidationError, self.controller.update, + req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body=body) def test_plan_update_InvalidId(self): - plan = self._plan_in_request_body() + plan = self._plan_update_request_body() body = {"plan": plan} req = fakes.HTTPRequest.blank('/v1/plans') self.assertRaises( exc.HTTPNotFound, self.controller.update, - req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body) + req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body=body) def test_plan_update_InvalidResources(self): - plan = self._plan_in_request_body( + plan = self._plan_update_request_body( name=DEFAULT_NAME, - description=DEFAULT_DESCRIPTION, - provider_id=DEFAULT_PROVIDER_ID, - status=constants.PLAN_STATUS_SUSPENDED, - project_id=DEFAULT_PROJECT_ID, resources=[{'key1': 'value1'}]) body = {"plan": plan} req = fakes.HTTPRequest.blank('/v1/plans') self.assertRaises( exception.InvalidInput, self.controller.update, - req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body) + req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", body=body) @mock.patch( 'karbor.api.v1.plans.PlansController._get_all') @@ -179,7 +169,7 @@ class PlanApiTest(base.TestCase): plan = self._plan_in_request_body(parameters={}) body = {"plan": plan} req = fakes.HTTPRequest.blank('/v1/plans') - self.controller.create(req, body) + self.controller.create(req, body=body) @mock.patch( 'karbor.api.v1.plans.PlansController._plan_get') @@ -211,12 +201,9 @@ class PlanApiTest(base.TestCase): 'karbor.api.v1.plans.PlansController._plan_get') def test_plan_update_InvalidStatus( self, mock_plan_get): - plan = self._plan_in_request_body( + plan = self._plan_update_request_body( name=DEFAULT_NAME, - description=DEFAULT_DESCRIPTION, - provider_id=DEFAULT_PROVIDER_ID, status=constants.PLAN_STATUS_STARTED, - project_id=DEFAULT_PROJECT_ID, resources=DEFAULT_RESOURCES) body = {"plan": plan} req = fakes.HTTPRequest.blank('/v1/plans') @@ -224,23 +211,30 @@ class PlanApiTest(base.TestCase): self.assertRaises(exception.InvalidPlan, self.controller.update, req, "2a9ce1f3-cc1a-4516-9435-0ebb13caa398", - body) + body=body) def _plan_in_request_body(self, name=DEFAULT_NAME, description=DEFAULT_DESCRIPTION, provider_id=DEFAULT_PROVIDER_ID, - status=constants.PLAN_STATUS_SUSPENDED, - project_id=DEFAULT_PROJECT_ID, resources=DEFAULT_RESOURCES, parameters=DEFAULT_PARAMETERS): plan_req = { 'name': name, 'description': description, 'provider_id': provider_id, - 'status': status, - 'project_id': project_id, 'resources': resources, 'parameters': parameters, } return plan_req + + def _plan_update_request_body(self, name=DEFAULT_NAME, + status=constants.PLAN_STATUS_STARTED, + resources=DEFAULT_RESOURCES): + plan_req = { + 'name': name, + 'resources': resources, + 'status': status, + } + + return plan_req diff --git a/karbor/tests/unit/api/v1/test_scheduled_operation.py b/karbor/tests/unit/api/v1/test_scheduled_operation.py index 78c2eaea..a8d3ce07 100644 --- a/karbor/tests/unit/api/v1/test_scheduled_operation.py +++ b/karbor/tests/unit/api/v1/test_scheduled_operation.py @@ -208,5 +208,5 @@ class ScheduledOperationApiTest(base.TestCase): controller = plan_api.PlansController() mock_provider.return_value = fakes.PROVIDER_OS req = fakes.HTTPRequest.blank('/v1/plans') - plan = controller.create(req, create_plan_param) + plan = controller.create(req, body=create_plan_param) return plan['plan'] diff --git a/requirements.txt b/requirements.txt index 3fd3d419..b73be98c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ croniter>=0.3.4 # MIT License eventlet!=0.18.3,!=0.20.1,<0.21.0,>=0.18.2 # MIT greenlet>=0.4.10 # MIT icalendar>=3.10 # BSD +jsonschema<3.0.0,>=2.6.0 # MIT keystoneauth1>=3.2.0 # Apache-2.0 keystonemiddleware>=4.17.0 # Apache-2.0 kubernetes>=1.0.0 # Apache-2.0