From ccdf59f21e5c2771577b2087e33b81daf6033b6d Mon Sep 17 00:00:00 2001 From: justin-hopper Date: Tue, 18 Jun 2013 12:38:38 -0700 Subject: [PATCH] API Validation for Trove API Replaced validation with new and improved json schema validation Fixed malformed json schema bug #1177969 Fixed integration test for create_user; correct databases Fixed integration for create_users; invalid character_set param Implements: json-schema-support Change-Id: I6ca09803654d9e78362fde69185b5b9e05a5eb6b --- requirements.txt | 1 + trove/backup/service.py | 14 +- trove/common/apischema.py | 312 ++++++++++++++++++ trove/common/wsgi.py | 93 ++++-- trove/extensions/account/service.py | 2 + trove/extensions/mgmt/instances/service.py | 7 + trove/extensions/mysql/service.py | 53 +-- trove/extensions/security_group/service.py | 35 ++ trove/instance/service.py | 136 ++------ trove/tests/api/backups.py | 24 +- trove/tests/api/databases.py | 10 +- trove/tests/api/instances.py | 3 +- trove/tests/api/instances_actions.py | 2 +- trove/tests/api/instances_delete.py | 8 +- trove/tests/api/mgmt/malformed_json.py | 154 ++++++--- trove/tests/api/users.py | 32 +- .../backup/test_backup_controller.py | 43 +++ trove/tests/unittests/instance/__init__.py | 15 + .../instance/test_instance_controller.py | 217 ++++++++++++ trove/tests/unittests/mysql/__init__.py | 15 + .../unittests/mysql/test_user_controller.py | 252 ++++++++++++++ trove/tests/util/__init__.py | 8 - 22 files changed, 1170 insertions(+), 266 deletions(-) create mode 100644 trove/common/apischema.py create mode 100644 trove/tests/unittests/backup/test_backup_controller.py create mode 100644 trove/tests/unittests/instance/__init__.py create mode 100644 trove/tests/unittests/instance/test_instance_controller.py create mode 100644 trove/tests/unittests/mysql/__init__.py create mode 100644 trove/tests/unittests/mysql/test_user_controller.py diff --git a/requirements.txt b/requirements.txt index 79b75625eb..3a9c7f99f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,3 +18,4 @@ python-keystoneclient python-swiftclient iso8601 oslo.config>=1.1.0 +jsonschema>=1.0.0,!=1.4.0,<2 diff --git a/trove/backup/service.py b/trove/backup/service.py index 4a1f75eb2e..d10c018c76 100644 --- a/trove/backup/service.py +++ b/trove/backup/service.py @@ -22,6 +22,7 @@ from trove.common import cfg from trove.openstack.common import log as logging from trove.openstack.common.gettextutils import _ +import trove.common.apischema as apischema CONF = cfg.CONF LOG = logging.getLogger(__name__) @@ -31,6 +32,7 @@ class BackupController(wsgi.Controller): """ Controller for accessing backups in the OpenStack API. """ + schemas = apischema.backup def index(self, req, tenant_id): """ @@ -51,7 +53,6 @@ def show(self, req, tenant_id, id): def create(self, req, body, tenant_id): LOG.debug("Creating a Backup for tenant '%s'" % tenant_id) - self._validate_create_body(body) context = req.environ[wsgi.CONTEXT_KEY] data = body['backup'] instance = data['instance'] @@ -65,14 +66,3 @@ def delete(self, req, tenant_id, id): context = req.environ[wsgi.CONTEXT_KEY] Backup.delete(context, id) return wsgi.Result(None, 202) - - def _validate_create_body(self, body): - try: - body['backup'] - body['backup']['name'] - body['backup']['instance'] - except KeyError as e: - LOG.error(_("Create Backup Required field(s) " - "- %s") % e) - raise exception.TroveError( - "Required element/key - %s was not specified" % e) diff --git a/trove/common/apischema.py b/trove/common/apischema.py new file mode 100644 index 0000000000..c68ac107d1 --- /dev/null +++ b/trove/common/apischema.py @@ -0,0 +1,312 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# 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. +# + +flavorref = { + 'oneOf': [ + { + "type": "string", + "minLength": 8, + "pattern": 'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]' + '|(?:%[0-9a-fA-F][0-9a-fA-F]))+' + }, + { + "type": "string", + "maxLength": 5, + "pattern": "[0-9]+" + }, + { + "type": "integer" + }] +} + +volume_size = { + "oneOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "string", + "minLength": 1, + "pattern": "[0-9]+" + }] +} + +non_empty_string = { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^.*[0-9a-zA-Z]+.*$" +} + +uuid = { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-([0-9a-fA-F]){4}" + "-([0-9a-fA-F]){4}-([0-9a-fA-F]){12}$" +} + +volume = { + "type": "object", + "required": ["size"], + "properties": { + "size": volume_size, + "required": True + } +} + + +databases_ref_list = { + "type": "array", + "minItems": 0, + "uniqueItems": True, + "items": { + "type": "object", + "required": ["name"], + "additionalProperties": False, + "properties": { + "name": non_empty_string + } + } +} + +databases_ref_list_required = { + "type": "array", + "minItems": 1, + "uniqueItems": True, + "items": { + "type": "object", + "required": ["name"], + "additionalProperties": False, + "properties": { + "name": non_empty_string + } + } +} + +databases_ref = { + "type": "object", + "required": ["databases"], + "additionalProperties": False, + "properties": { + "databases": databases_ref_list_required + } +} + +databases_def = { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name"], + "additionalProperties": False, + "properties": { + "name": non_empty_string, + "character_set": non_empty_string, + "collate": non_empty_string + } + } +} + +users_list = { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["name", "password"], + "additionalProperties": False, + "properties": { + "name": non_empty_string, + "password": non_empty_string, + "host": non_empty_string, + "databases": databases_ref_list + } + } +} + +instance = { + "create": { + "type": "object", + "required": ["instance"], + "additionalProperties": False, + "properties": { + "instance": { + "type": "object", + "required": ["name", "flavorRef", "volume"], + "additionalProperties": False, + "properties": { + "name": non_empty_string, + "flavorRef": flavorref, + "volume": volume, + "databases": databases_def, + "users": users_list, + "restorePoint": { + "type": "object", + "required": ["backupRef"], + "additionalProperties": False, + "properties": { + "backupRef": uuid + } + } + } + } + } + }, + "action": { + "resize": { + "volume": { + "type": "object", + "required": ["resize"], + "additionalProperties": False, + "properties": { + "resize": { + "type": "object", + "required": ["volume"], + "additionalProperties": False, + "properties": { + "volume": volume + } + } + } + }, + 'flavorRef': { + "type": "object", + "required": ["resize"], + "additionalProperties": False, + "properties": { + "resize": { + "type": "object", + "required": ["flavorRef"], + "additionalProperties": False, + "properties": { + "flavorRef": flavorref + } + } + } + } + }, + "restart": { + "type": "object", + "required": ["restart"], + "additionalProperties": False, + "properties": { + "restart": { + "type": "object" + } + } + } + } +} + +mgmt_instance = { + "action": { + 'migrate': { + "type": "object", + "required": ["migrate"], + "additionalProperties": False, + "properties": { + "migrate": { + "type": "object" + } + } + }, + "reboot": { + "type": "object", + "required": ["reboot"], + "additionalProperties": False, + "properties": { + "reboot": { + "type": "object" + } + } + }, + "stop": { + "type": "object", + "required": ["stop"], + "additionalProperties": False, + "properties": { + "stop": { + "type": "object" + } + } + } + } +} + +user = { + "create": { + "name": "users:create", + "type": "object", + "required": ["users"], + "properties": { + "users": users_list + } + }, + "update": { + "users": { + "type": "object", + "required": ["users"], + "additionalProperties": False, + "properties": { + "users": users_list + } + }, + "databases": databases_ref + } +} + +dbschema = { + "create": { + "type": "object", + "required": ["databases"], + "additionalProperties": False, + "properties": { + "databases": databases_def + } + } +} + +backup = { + "create": { + "name": "backup:create", + "type": "object", + "required": ["backup"], + "properties": { + "backup": { + "type": "object", + "required": ["instance", "name"], + "properties": { + "description": non_empty_string, + "instance": uuid, + "name": non_empty_string + } + } + } + } +} + +account = { + 'create': { + "type": "object", + "name": "users", + "required": ["users"], + "additionalProperties": False, + "properties": { + "users": users_list + } + } +} diff --git a/trove/common/wsgi.py b/trove/common/wsgi.py index fde1a07087..7e6bc75a42 100644 --- a/trove/common/wsgi.py +++ b/trove/common/wsgi.py @@ -18,6 +18,7 @@ import eventlet.wsgi import math +import jsonschema import paste.urlmap import re import time @@ -27,7 +28,6 @@ import webob.dec import webob.exc from lxml import etree -from paste import deploy from xml.dom import minidom from trove.common import context as rd_context @@ -156,11 +156,11 @@ def decorator(func): func.wsgi_serializers = {} func.wsgi_serializers.update(serializers) return func + return decorator class TroveMiddleware(Middleware): - # Note: taken from nova @classmethod def factory(cls, global_config, **local_config): @@ -185,13 +185,14 @@ def factory(cls, global_config, **local_config): but using the kwarg passing it shouldn't be necessary. """ + def _factory(app): return cls(app, **local_config) + return _factory class VersionedURLMap(object): - def __init__(self, urlmap): self.urlmap = urlmap @@ -208,7 +209,6 @@ def __call__(self, environ, start_response): class Router(openstack_wsgi.Router): - # Original router did not allow for serialization of the 404 error. # To fix this the _dispatch was modified to use Fault() objects. @staticmethod @@ -228,7 +228,6 @@ def _dispatch(req): class Request(openstack_wsgi.Request): - @property def params(self): return utils.stringify_keys(super(Request, self).params) @@ -303,7 +302,6 @@ def data(self, serialization_type): class Resource(openstack_wsgi.Resource): - def __init__(self, controller, deserializer, serializer, exception_map=None): exception_map = exception_map or {} @@ -318,6 +316,7 @@ def execute_action(self, action, request, **action_args): if getattr(self.controller, action, None) is None: return Fault(webob.exc.HTTPNotFound()) try: + self.controller.validate_request(action, action_args) result = super(Resource, self).execute_action( action, request, @@ -382,8 +381,6 @@ def serialize_response(self, action, action_result, accept): class Controller(object): """Base controller that creates a Resource with default serializers.""" - exclude_attr = [] - exception_map = { webob.exc.HTTPUnprocessableEntity: [ exception.UnprocessableEntity, @@ -428,6 +425,38 @@ class Controller(object): ], } + schemas = {} + + @classmethod + def get_schema(cls, action, body): + LOG.debug("Getting schema for %s:%s" % + (cls.__class__.__name__, action)) + if cls.schemas: + matching_schema = cls.schemas.get(action, {}) + if matching_schema: + LOG.debug("Found Schema: %s" % matching_schema.get("name", + "none")) + return matching_schema + + def validate_request(self, action, action_args): + body = action_args.get('body', {}) + schema = self.get_schema(action, body) + if schema: + validator = jsonschema.Draft4Validator(schema) + if not validator.is_valid(body): + errors = sorted(validator.iter_errors(body), + key=lambda e: e.path) + messages = [] + for error in errors: + messages.append(error.message) + for suberror in sorted(error.context, + key=lambda e: e.schema_path): + messages.append(suberror.message) + error_msg = "; ".join(messages) + LOG.info("Validation failed: %s" % error_msg) + raise exception.BadRequest( + message="Validation error: %s" % error_msg) + def create_resource(self): serializer = TroveResponseSerializer( body_serializers={'application/xml': TroveXMLDictSerializer()}) @@ -441,12 +470,6 @@ def _extract_limits(self, params): return dict([(key, params[key]) for key in params.keys() if key in ["limit", "marker"]]) - def _extract_required_params(self, params, model_name): - params = params or {} - model_params = params.get(model_name, {}) - return utils.stringify_keys(utils.exclude(model_params, - *self.exclude_attr)) - class TroveRequestDeserializer(RequestDeserializer): """Break up a Request object into more useful pieces.""" @@ -462,14 +485,12 @@ def __init__(self, body_deserializers=None, headers_deserializer=None, class TroveXMLDeserializer(XMLDeserializer): - def __init__(self, metadata=None): """ :param metadata: information needed to deserialize xml into a dictionary. """ - if metadata is None: - metadata = {} + metadata = metadata or {} metadata['plurals'] = CUSTOM_PLURALS_METADATA super(TroveXMLDeserializer, self).__init__(metadata) @@ -478,11 +499,37 @@ def default(self, datastring): # hub-cap: This feels wrong but minidom keeps the newlines # and spaces as childNodes which is expected behavior. return {'body': self._from_xml(re.sub(r'((?<=>)\s+)*\n*(\s+(?=<))*', - '', datastring))} + '', datastring))} + def _from_xml_node(self, node, listnames): + """Convert a minidom node to a simple Python type. -class TroveXMLDictSerializer(openstack_wsgi.XMLDictSerializer): + Overridden from openstack deserializer to skip xmlns attributes and + remove certain unicode characters + + :param listnames: list of XML node names whose subnodes should + be considered list items. + + """ + + if len(node.childNodes) == 1 and node.childNodes[0].nodeType == 3: + return node.childNodes[0].nodeValue + elif node.nodeName in listnames: + return [self._from_xml_node(n, listnames) for n in node.childNodes] + else: + result = dict() + for attr in node.attributes.keys(): + if attr == 'xmlns': + continue + result[attr] = node.attributes[attr].nodeValue + for child in node.childNodes: + if child.nodeType != node.TEXT_NODE: + result[child.nodeName] = self._from_xml_node(child, + listnames) + return result + +class TroveXMLDictSerializer(openstack_wsgi.XMLDictSerializer): def __init__(self, metadata=None, xmlns=None): super(TroveXMLDictSerializer, self).__init__(metadata, XMLNS) @@ -526,7 +573,6 @@ def _to_xml_node(self, doc, metadata, nodename, data): class TroveResponseSerializer(openstack_wsgi.ResponseSerializer): - def serialize_body(self, response, data, content_type, action): """Overrides body serialization in openstack_wsgi.ResponseSerializer. @@ -587,7 +633,7 @@ def _get_error_name(exc): name = exc.__class__.__name__ if name in named_exceptions: return named_exceptions[name] - # If the exception isn't in our list, at least strip off the + # If the exception isn't in our list, at least strip off the # HTTP from the name, and then drop the case on the first letter. name = name.split("HTTP").pop() name = name[:1].lower() + name[1:] @@ -623,14 +669,13 @@ def __call__(self, req): class ContextMiddleware(openstack_wsgi.Middleware): - def __init__(self, application): self.admin_roles = CONF.admin_roles super(ContextMiddleware, self).__init__(application) def _extract_limits(self, params): return dict([(key, params[key]) for key in params.keys() - if key in ["limit", "marker"]]) + if key in ["limit", "marker"]]) def process_request(self, request): tenant_id = request.headers.get('X-Tenant-Id', None) @@ -657,6 +702,7 @@ def _factory(app): LOG.debug(_("Created context middleware with config: %s") % local_config) return cls(app) + return _factory @@ -764,7 +810,6 @@ def default(self, data): class XMLDictSerializer(DictSerializer): - def __init__(self, metadata=None, xmlns=None): """ :param metadata: information needed to deserialize xml into diff --git a/trove/extensions/account/service.py b/trove/extensions/account/service.py index beeb39ac38..8c2e5e6eaa 100644 --- a/trove/extensions/account/service.py +++ b/trove/extensions/account/service.py @@ -26,12 +26,14 @@ from trove.extensions.account import views from trove.instance.models import DBInstance from trove.openstack.common.gettextutils import _ +import trove.common.apischema as apischema LOG = logging.getLogger(__name__) class AccountController(wsgi.Controller): """Controller for account functionality""" + schemas = apischema.account @admin_context def show(self, req, tenant_id, id): diff --git a/trove/extensions/mgmt/instances/service.py b/trove/extensions/mgmt/instances/service.py index 16b26ed017..a34758465c 100644 --- a/trove/extensions/mgmt/instances/service.py +++ b/trove/extensions/mgmt/instances/service.py @@ -30,6 +30,7 @@ from trove.instance.service import InstanceController from trove.openstack.common import log as logging from trove.openstack.common.gettextutils import _ +import trove.common.apischema as apischema LOG = logging.getLogger(__name__) @@ -37,6 +38,12 @@ class MgmtInstanceController(InstanceController): """Controller for instance functionality""" + schemas = apischema.mgmt_instance + + @classmethod + def get_action_schema(cls, body, action_schema): + action_type = body.keys()[0] + return action_schema.get(action_type, {}) @admin_context def index(self, req, tenant_id, detailed=False): diff --git a/trove/extensions/mysql/service.py b/trove/extensions/mysql/service.py index b77c2aff08..57b7ff9bdd 100644 --- a/trove/extensions/mysql/service.py +++ b/trove/extensions/mysql/service.py @@ -28,8 +28,8 @@ from trove.guestagent.db import models as guest_models from trove.openstack.common import log as logging from trove.openstack.common.gettextutils import _ +import trove.common.apischema as apischema -from urllib import unquote LOG = logging.getLogger(__name__) @@ -58,19 +58,15 @@ def create(self, req, tenant_id, instance_id): class UserController(wsgi.Controller): """Controller for instance functionality""" + schemas = apischema.user @classmethod - def validate(cls, body): - """Validate that the request has all the required parameters""" - if not body: - raise exception.BadRequest("The request contains an empty body") - if body.get('users') is None: - raise exception.MissingKey(key='users') - for user in body.get('users'): - if not user.get('name'): - raise exception.MissingKey(key='name') - if not user.get('password'): - raise exception.MissingKey(key='password') + def get_schema(cls, action, body): + action_schema = super(UserController, cls).get_schema(action, body) + if 'update' == action: + update_type = body.keys()[0] + action_schema = action_schema.get(update_type, {}) + return action_schema def index(self, req, tenant_id, instance_id): """Return all users.""" @@ -89,7 +85,6 @@ def create(self, req, body, tenant_id, instance_id): LOG.info(_("req : '%s'\n\n") % req) LOG.info(_("body : '%s'\n\n") % body) context = req.environ[wsgi.CONTEXT_KEY] - self.validate(body) users = body['users'] try: model_users = populate_users(users) @@ -140,7 +135,6 @@ def update(self, req, body, tenant_id, instance_id): LOG.info(_("Updating user passwords for instance '%s'") % instance_id) LOG.info(_("req : '%s'\n\n") % req) context = req.environ[wsgi.CONTEXT_KEY] - self.validate(body) users = body['users'] model_users = [] for user in users: @@ -165,19 +159,14 @@ def update(self, req, body, tenant_id, instance_id): class UserAccessController(wsgi.Controller): """Controller for adding and removing database access for a user.""" + schemas = apischema.user @classmethod - def validate(cls, body): - """Validate that the request has all the required parameters""" - if not body: - raise exception.BadRequest("The request contains an empty body") - if not body.get('databases', []): - raise exception.MissingKey(key='databases') - if type(body['databases']) is not list: - raise exception.BadRequest("Databases must be provided as a list.") - for database in body.get('databases'): - if not database.get('name', ''): - raise exception.MissingKey(key='name') + def get_schema(cls, action, body): + schema = {} + if 'update' == action: + schema = cls.schemas.get(action).get('databases') + return schema def _get_user(self, context, instance_id, user_id): username, hostname = unquote_user_host(user_id) @@ -206,7 +195,6 @@ def update(self, req, body, tenant_id, instance_id, user_id): LOG.info(_("Granting user access for instance '%s'") % instance_id) LOG.info(_("req : '%s'\n\n") % req) context = req.environ[wsgi.CONTEXT_KEY] - self.validate(body) user = self._get_user(context, instance_id, user_id) username, hostname = unquote_user_host(user_id) databases = [db['name'] for db in body['databases']] @@ -230,17 +218,7 @@ def delete(self, req, tenant_id, instance_id, user_id, id): class SchemaController(wsgi.Controller): """Controller for instance functionality""" - - @classmethod - def validate(cls, body): - """Validate that the request has all the required parameters""" - if not body: - raise exception.BadRequest("The request contains an empty body") - if not body.get('databases', ''): - raise exception.MissingKey(key='databases') - for database in body.get('databases'): - if not database.get('name', ''): - raise exception.MissingKey(key='name') + schemas = apischema.dbschema def index(self, req, tenant_id, instance_id): """Return all schemas.""" @@ -259,7 +237,6 @@ def create(self, req, body, tenant_id, instance_id): LOG.info(_("req : '%s'\n\n") % req) LOG.info(_("body : '%s'\n\n") % body) context = req.environ[wsgi.CONTEXT_KEY] - self.validate(body) schemas = body['databases'] model_schemas = populate_validated_databases(schemas) models.Schema.create(context, instance_id, model_schemas) diff --git a/trove/extensions/security_group/service.py b/trove/extensions/security_group/service.py index 4a16deeba1..6655f66350 100644 --- a/trove/extensions/security_group/service.py +++ b/trove/extensions/security_group/service.py @@ -115,3 +115,38 @@ def _validate_create_body(self, body): "- %s") % e) raise exception.SecurityGroupRuleCreationError( "Required element/key - %s was not specified" % e) + + schemas = { + "type": "object", + "name": "security_group_rule:create", + "required": True, + "properties": { + "security_group_rule": { + "type": "object", + "required": True, + "properties": { + "cidr": { + "type": "string", + "required": True, + "minLength": 9, + "maxLength": 18 + }, + "group_id": { + "type": "string", + "required": True, + "maxLength": 255 + }, + "from_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "to_port": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + } + } + } + } + } diff --git a/trove/instance/service.py b/trove/instance/service.py index b4c19e7eff..4d407f8fdd 100644 --- a/trove/instance/service.py +++ b/trove/instance/service.py @@ -29,33 +29,44 @@ from trove.backup import views as backup_views from trove.openstack.common import log as logging from trove.openstack.common.gettextutils import _ +import trove.common.apischema as apischema CONF = cfg.CONF LOG = logging.getLogger(__name__) -class api_validation: - """ api validation wrapper """ - def __init__(self, action=None): - self.action = action - - def __call__(self, f): - """ - Apply validation of the api body - """ - def wrapper(*args, **kwargs): - body = kwargs['body'] - if self.action == 'create': - InstanceController._validate(body) - return f(*args, **kwargs) - return wrapper - - class InstanceController(wsgi.Controller): """Controller for instance functionality""" + schemas = apischema.instance + + @classmethod + def get_action_schema(cls, body, action_schema): + action_type = body.keys()[0] + action_schema = action_schema.get(action_type, {}) + if action_type == 'resize': + # volume or flavorRef + resize_action = body[action_type].keys()[0] + action_schema = action_schema.get(resize_action, {}) + return action_schema + + @classmethod + def get_schema(cls, action, body): + action_schema = super(InstanceController, cls).get_schema(action, body) + if action == 'action': + # resize or restart + action_schema = cls.get_action_schema(body, action_schema) + return action_schema def action(self, req, body, tenant_id, id): + """ + Handles requests that modify existing instances in some manner. Actions + could include 'resize', 'restart', 'reset_password' + :param req: http request object + :param body: deserialized body of the request as a dict + :param tenant_id: the tenant id for whom owns the instance + :param id: ??? + """ LOG.info("req : '%s'\n\n" % req) LOG.info("Comitting an ACTION again instance %s for tenant '%s'" % (id, tenant_id)) @@ -71,18 +82,8 @@ def action(self, req, body, tenant_id, id): selected_action = None for key in body: if key in _actions: - if selected_action is not None: - msg = _("Only one action can be specified per request.") - raise exception.BadRequest(msg) selected_action = _actions[key] - else: - msg = _("Invalid instance action: %s") % key - raise exception.BadRequest(msg) - - if selected_action: - return selected_action(instance, body) - else: - raise exception.BadRequest(_("Invalid request body.")) + return selected_action(instance, body) def _action_restart(self, instance, body): instance.restart() @@ -106,20 +107,12 @@ def _action_resize(self, instance, body): args = None for key in options: if key in body['resize']: - if selected_option is not None: - msg = _("Not allowed to resize volume and flavor at the " - "same time.") - raise exception.BadRequest(msg) selected_option = options[key] args = body['resize'][key] - - if selected_option: - return selected_option(instance, args) - else: - raise exception.BadRequest(_("Missing resize arguments.")) + break + return selected_option(instance, args) def _action_resize_volume(self, instance, volume): - InstanceController._validate_resize_volume(volume) instance.resize_volume(volume['size']) return wsgi.Result(None, 202) @@ -175,7 +168,6 @@ def delete(self, req, tenant_id, id): # TODO(cp16net): need to set the return code correctly return wsgi.Result(None, 202) - @api_validation(action="create") def create(self, req, body, tenant_id): # TODO(hub-cap): turn this into middleware LOG.info(_("Creating a database instance for tenant '%s'") % tenant_id) @@ -197,18 +189,15 @@ def create(self, req, body, tenant_id): users = populate_users(body['instance'].get('users', [])) except ValueError as ve: raise exception.BadRequest(msg=ve) + if 'volume' in body['instance']: - try: - volume_size = int(body['instance']['volume']['size']) - except ValueError as e: - raise exception.BadValue(msg=e) + volume_size = int(body['instance']['volume']['size']) else: volume_size = None if 'restorePoint' in body['instance']: backupRef = body['instance']['restorePoint']['backupRef'] backup_id = utils.get_id_from_href(backupRef) - else: backup_id = None @@ -219,62 +208,3 @@ def create(self, req, body, tenant_id): view = views.InstanceDetailView(instance, req=req) return wsgi.Result(view.data(), 200) - - @staticmethod - def _validate_body_not_empty(body): - """Check that the body is not empty""" - if not body: - msg = "The request contains an empty body" - raise exception.TroveError(msg) - - @staticmethod - def _validate_resize_volume(volume): - """ - We are going to check that volume resizing data is present. - """ - if 'size' not in volume: - raise exception.BadRequest( - "Missing 'size' property of 'volume' in request body.") - InstanceController._validate_volume_size(volume['size']) - - @staticmethod - def _validate_volume_size(size): - """Validate the various possible errors for volume size""" - try: - volume_size = float(size) - except (ValueError, TypeError) as err: - LOG.error(err) - msg = ("Required element/key - instance volume 'size' was not " - "specified as a number (value was %s)." % size) - raise exception.TroveError(msg) - if int(volume_size) != volume_size or int(volume_size) < 1: - msg = ("Volume 'size' needs to be a positive " - "integer value, %s cannot be accepted." - % volume_size) - raise exception.TroveError(msg) - - @staticmethod - def _validate(body): - """Validate that the request has all the required parameters""" - InstanceController._validate_body_not_empty(body) - - try: - body['instance'] - body['instance']['flavorRef'] - name = body['instance'].get('name', '').strip() - if not name: - raise exception.MissingKey(key='name') - if CONF.trove_volume_support: - if body['instance'].get('volume', None): - if body['instance']['volume'].get('size', None): - volume_size = body['instance']['volume']['size'] - InstanceController._validate_volume_size(volume_size) - else: - raise exception.MissingKey(key="size") - else: - raise exception.MissingKey(key="volume") - - except KeyError as e: - LOG.error(_("Create Instance Required field(s) - %s") % e) - raise exception.TroveError("Required element/key - %s " - "was not specified" % e) diff --git a/trove/tests/api/backups.py b/trove/tests/api/backups.py index 8f0c4c8f80..cf92b63103 100644 --- a/trove/tests/api/backups.py +++ b/trove/tests/api/backups.py @@ -12,12 +12,14 @@ # 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 uuid from proboscis.asserts import assert_equal from proboscis.asserts import assert_not_equal from proboscis.asserts import assert_raises from proboscis import test from proboscis import SkipTest from proboscis.decorators import time_out +import troveclient from trove.tests.util import poll_until from trove.tests.util import test_config from trove.tests.util import create_dbaas_client @@ -28,6 +30,7 @@ from trove.tests.api.instances import instance_info from trove.tests.api.instances import assert_unprocessable + GROUP = "dbaas.api.backups" BACKUP_NAME = 'backup_test' BACKUP_DESC = 'test description' @@ -41,11 +44,30 @@ groups=[GROUP]) class CreateBackups(object): + @test + def test_backup_create_instance_invalid(self): + """test create backup with unknown instance""" + invalid_inst_id = 'invalid-inst-id' + try: + instance_info.dbaas.backups.create(BACKUP_NAME, invalid_inst_id, + BACKUP_DESC) + except exceptions.BadRequest as e: + resp, body = instance_info.dbaas.client.last_response + assert_equal(resp.status, 400) + if not isinstance(instance_info.dbaas.client, + troveclient.xml.TroveXmlClient): + assert_equal(e.message, "Validation error: u'%s' " + "does not match " + "'^([0-9a-fA-F]){8}-([0-9a-fA-F]){4}-" + "([0-9a-fA-F]){4}-([0-9a-fA-F]){4}-" + "([0-9a-fA-F]){12}$'" % + invalid_inst_id) + @test def test_backup_create_instance_not_found(self): """test create backup with unknown instance""" assert_raises(exceptions.NotFound, instance_info.dbaas.backups.create, - BACKUP_NAME, 'nonexistent_instance', BACKUP_DESC) + BACKUP_NAME, str(uuid.uuid4()), BACKUP_DESC) @test def test_backup_create_instance(self): diff --git a/trove/tests/api/databases.py b/trove/tests/api/databases.py index d5514916d8..95ceab944c 100644 --- a/trove/tests/api/databases.py +++ b/trove/tests/api/databases.py @@ -83,7 +83,7 @@ def setUp(self): @test def test_cannot_create_taboo_database_names(self): for name in self.system_dbs: - databases = [{"name": name, "charset": "latin2", + databases = [{"name": name, "character_set": "latin2", "collate": "latin2_general_ci"}] assert_raises(exceptions.BadRequest, self.dbaas.databases.create, instance_info.id, databases) @@ -92,7 +92,7 @@ def test_cannot_create_taboo_database_names(self): @test def test_create_database(self): databases = [] - databases.append({"name": self.dbname, "charset": "latin2", + databases.append({"name": self.dbname, "character_set": "latin2", "collate": "latin2_general_ci"}) databases.append({"name": self.dbname2}) @@ -116,7 +116,7 @@ def test_create_database_list(self): @test(depends_on=[test_create_database]) def test_fails_when_creating_a_db_twice(self): databases = [] - databases.append({"name": self.dbname, "charset": "latin2", + databases.append({"name": self.dbname, "character_set": "latin2", "collate": "latin2_general_ci"}) databases.append({"name": self.dbname2}) @@ -138,7 +138,7 @@ def test_create_database_list_system(self): @test def test_create_database_on_missing_instance(self): - databases = [{"name": "invalid_db", "charset": "latin2", + databases = [{"name": "invalid_db", "character_set": "latin2", "collate": "latin2_general_ci"}] assert_raises(exceptions.NotFound, self.dbaas.databases.create, -1, databases) @@ -190,7 +190,7 @@ def test_invalid_database_name(self): @test def test_pagination(self): databases = [] - databases.append({"name": "Sprockets", "charset": "latin2", + databases.append({"name": "Sprockets", "character_set": "latin2", "collate": "latin2_general_ci"}) databases.append({"name": "Cogs"}) databases.append({"name": "Widgets"}) diff --git a/trove/tests/api/instances.py b/trove/tests/api/instances.py index dfbab06774..c88e928786 100644 --- a/trove/tests/api/instances.py +++ b/trove/tests/api/instances.py @@ -61,7 +61,6 @@ from trove.tests.util import iso_time from trove.tests.util import process from trove.tests.util.users import Requirements -from trove.tests.util import skip_if_xml from trove.tests.util import string_in_list from trove.tests.util import poll_until from trove.tests.util.check import AttrCheck @@ -641,7 +640,7 @@ def test_users_delete_after_create(self): def test_users_create_after_create(self): users = list() users.append({"name": "testuser", "password": "password", - "database": "testdb"}) + "databases": [{"name": "testdb"}]}) assert_unprocessable(dbaas.users.create, instance_info.id, users) def test_resize_instance_after_create(self): diff --git a/trove/tests/api/instances_actions.py b/trove/tests/api/instances_actions.py index ab82df261c..1aa4289906 100644 --- a/trove/tests/api/instances_actions.py +++ b/trove/tests/api/instances_actions.py @@ -120,7 +120,7 @@ def create_user(self): """Create a MySQL user we can use for this test.""" users = [{"name": MYSQL_USERNAME, "password": MYSQL_PASSWORD, - "database": MYSQL_USERNAME}] + "databases": [{"name": MYSQL_USERNAME}]}] self.dbaas.users.create(instance_info.id, users) def has_user(): diff --git a/trove/tests/api/instances_delete.py b/trove/tests/api/instances_delete.py index 0eb631c987..78efd02e82 100644 --- a/trove/tests/api/instances_delete.py +++ b/trove/tests/api/instances_delete.py @@ -91,22 +91,22 @@ def set_up(self): self.delete_error = self.create_instance('test_ERROR_ON_DELETE') @test - @time_out(20) + @time_out(30) def delete_server_error(self): self.delete_errored_instance(self.server_error) @test(enabled=VOLUME_SUPPORT) - @time_out(20) + @time_out(30) def delete_volume_error(self): self.delete_errored_instance(self.volume_error) @test(enabled=False) - @time_out(20) + @time_out(30) def delete_dns_error(self): self.delete_errored_instance(self.dns_error) @test - @time_out(20) + @time_out(30) def delete_error_on_delete_instance(self): id = self.delete_error self.wait_for_instance_status(id, 'ACTIVE') diff --git a/trove/tests/api/mgmt/malformed_json.py b/trove/tests/api/mgmt/malformed_json.py index 7b25667712..bc6c022e3d 100644 --- a/trove/tests/api/mgmt/malformed_json.py +++ b/trove/tests/api/mgmt/malformed_json.py @@ -1,24 +1,20 @@ -from proboscis import test +from proboscis import test, SkipTest from proboscis.asserts import * from proboscis import after_class from proboscis import before_class -from proboscis.asserts import Check +import troveclient from trove.tests.config import CONFIG from trove.tests.api.instances import instance_info from trove.tests.api.instances import VOLUME_SUPPORT -from troveclient import exceptions -import json -import requests from trove.tests.util.users import Requirements from trove.tests.util import create_dbaas_client +import trove.tests.util as tests_utils from trove.tests.util import poll_until -from nose.plugins.skip import SkipTest @test(groups=["dbaas.api.mgmt.malformed_json"]) class MalformedJson(object): - @before_class def setUp(self): self.reqs = Requirements(is_admin=False) @@ -40,30 +36,46 @@ def tearDown(self): @test def test_bad_instance_data(self): - raise SkipTest("Please see Launchpad Bug #1177969") + databases = "foo" + users = "bar" try: self.dbaas.instances.create("bad_instance", 3, 3, - databases="foo", - users="bar") + databases=databases, + users=users) except Exception as e: resp, body = self.dbaas.client.last_response httpCode = resp.status - assert_true(httpCode == 400, - "Create instance failed with code %s, exception %s" - % (httpCode, e)) + assert_equal(httpCode, 400, + "Create instance failed with code %s, exception %s" + % (httpCode, e)) + if not isinstance(self.dbaas.client, + troveclient.xml.TroveXmlClient): + databases = "u'foo'" + users = "u'bar'" + assert_equal(e.message, + "Validation error: " + "%s is not of type 'array'; " + "%s is not of type 'array'; " + "3 is not of type 'object'" % (databases, users)) @test def test_bad_database_data(self): - raise SkipTest("Please see Launchpad Bug #1177969") + tests_utils.skip_if_xml() _bad_db_data = "{foo}" try: self.dbaas.databases.create(self.instance.id, _bad_db_data) except Exception as e: resp, body = self.dbaas.client.last_response httpCode = resp.status - assert_true(httpCode == 400, - "Create database failed with code %s, exception %s" - % (httpCode, e)) + assert_equal(httpCode, 400, + "Create database failed with code %s, exception %s" + % (httpCode, e)) + if not isinstance(self.dbaas.client, + troveclient.xml.TroveXmlClient): + _bad_db_data = "u'{foo}'" + assert_equal(e.message, + "Validation error: %s is not of type 'array'" % + _bad_db_data) @test def test_bad_user_data(self): @@ -76,29 +88,34 @@ def test_bad_user_data(self): except Exception as e: resp, body = self.dbaas.client.last_response httpCode = resp.status - assert_true(httpCode == 400, - "Create user failed with code %s, exception %s" - % (httpCode, e)) + assert_equal(httpCode, 400, + "Create user failed with code %s, exception %s" + % (httpCode, e)) + assert_equal(e.message, + "Validation error: Additional properties are not " + "allowed " + "(u'password12', u'name12' were unexpected); " + "'name' is a required property; " + "'password' is a required property") @test def test_bad_resize_instance_data(self): - raise SkipTest("Please see Launchpad Bug #1177969") - def _check_instance_status(): inst = self.dbaas.instances.get(self.instance) if inst.status == "ACTIVE": return True else: return False + poll_until(_check_instance_status) try: self.dbaas.instances.resize_instance(self.instance.id, "bad data") except Exception as e: resp, body = self.dbaas.client.last_response httpCode = resp.status - assert_true(httpCode == 400, - "Resize instance failed with code %s, exception %s" - % (httpCode, e)) + assert_equal(httpCode, 400, + "Resize instance failed with code %s, exception %s" + % (httpCode, e)) @test def test_bad_resize_vol_data(self): @@ -108,19 +125,28 @@ def _check_instance_status(): return True else: return False + poll_until(_check_instance_status) + data = "bad data" try: - self.dbaas.instances.resize_volume(self.instance.id, "bad data") + self.dbaas.instances.resize_volume(self.instance.id, data) except Exception as e: resp, body = self.dbaas.client.last_response httpCode = resp.status - assert_true(httpCode == 400, - "Resize instance failed with code %s, exception %s" - % (httpCode, e)) + assert_equal(httpCode, 400, + "Resize instance failed with code %s, exception %s" + % (httpCode, e)) + data = "u'bad data'" + assert_equal(e.message, + "Validation error: " + "%s is not valid under any of the given schemas; " + "%s is not of type 'integer'; " + "%s does not match '[0-9]+'" % (data, data, data)) @test def test_bad_change_user_password(self): - users = [{"name": ""}] + password = "" + users = [{"name": password}] def _check_instance_status(): inst = self.dbaas.instances.get(self.instance) @@ -128,15 +154,25 @@ def _check_instance_status(): return True else: return False + poll_until(_check_instance_status) try: self.dbaas.users.change_passwords(self.instance, users) except Exception as e: resp, body = self.dbaas.client.last_response httpCode = resp.status - assert_true(httpCode == 400, - "Change usr/passwd failed with code %s, exception %s" % - (httpCode, e)) + assert_equal(httpCode, 400, + "Change usr/passwd failed with code %s, exception %s" + % (httpCode, e)) + if not isinstance(self.dbaas.client, + troveclient.xml.TroveXmlClient): + password = "u''" + assert_equal(e.message, "Validation error: " + "'password' is a required property; " + "%s is too short; " + "%s does not match " + "'^.*[0-9a-zA-Z]+.*$'" % + (password, password)) @test def test_bad_grant_user_access(self): @@ -148,15 +184,17 @@ def _check_instance_status(): return True else: return False + poll_until(_check_instance_status) try: self.dbaas.users.grant(self.instance, self.user, dbs) except Exception as e: resp, body = self.dbaas.client.last_response httpCode = resp.status - assert_true(httpCode == 400, - "Grant user access failed with code %s, exception %s" % - (httpCode, e)) + assert_equal(httpCode, 400, + "Grant user access failed with code %s, exception " + "%s" % + (httpCode, e)) @test def test_bad_revoke_user_access(self): @@ -168,19 +206,22 @@ def _check_instance_status(): return True else: return False + poll_until(_check_instance_status) try: self.dbaas.users.revoke(self.instance, self.user, db) except Exception as e: resp, body = self.dbaas.client.last_response httpCode = resp.status - assert_true(httpCode == 404, - "Revoke user access failed w/code %s, exception %s" % - (httpCode, e)) + assert_equal(httpCode, 404, + "Revoke user access failed w/code %s, exception %s" % + (httpCode, e)) + assert_equal(e.message, "The resource could not be found.") @test def test_bad_body_flavorid_create_instance(self): - raise SkipTest("Please see Launchpad Bug #1177969") + tests_utils.skip_if_xml() + flavorId = ["?"] try: self.dbaas.instances.create("test_instance", @@ -189,14 +230,25 @@ def test_bad_body_flavorid_create_instance(self): except Exception as e: resp, body = self.dbaas.client.last_response httpCode = resp.status - assert_true(httpCode == 400, - "Create instance failed with code %s, exception %s" % - (httpCode, e)) + assert_equal(httpCode, 400, + "Create instance failed with code %s, exception %s" % + (httpCode, e)) + + if not isinstance(self.dbaas.client, + troveclient.xml.TroveXmlClient): + flavorId = [u'?'] + assert_equal(e.message, + "Validation error: %s is not valid under any " + "of the given schemas; " + "%s is not of type 'string'; " + "%s is not of type 'string'; " + "%s is not of type 'integer'; " + "2 is not of type 'object'" % + (flavorId, flavorId, flavorId, flavorId)) @test def test_bad_body_volsize_create_instance(self): - raise SkipTest("Please see Launchpad Bug #1177969") - volsize = ("h3ll0") + volsize = "h3ll0" try: self.dbaas.instances.create("test_instance", "1", @@ -204,6 +256,12 @@ def test_bad_body_volsize_create_instance(self): except Exception as e: resp, body = self.dbaas.client.last_response httpCode = resp.status - assert_true(httpCode == 400, - "Create instance failed with code %s, exception %s" % - (httpCode, e)) + assert_equal(httpCode, 400, + "Create instance failed with code %s, exception %s" % + (httpCode, e)) + if not isinstance(self.dbaas.client, + troveclient.xml.TroveXmlClient): + volsize = "u'h3ll0'" + assert_equal(e.message, + "Validation error: %s is not of type 'object'" % + volsize) diff --git a/trove/tests/api/users.py b/trove/tests/api/users.py index 607db0dd2e..189bdcfe76 100644 --- a/trove/tests/api/users.py +++ b/trove/tests/api/users.py @@ -13,7 +13,6 @@ # under the License. import time -import re from troveclient import exceptions @@ -22,23 +21,17 @@ from proboscis import test from proboscis.asserts import assert_equal from proboscis.asserts import assert_false -from proboscis.asserts import assert_not_equal from proboscis.asserts import assert_raises from proboscis.asserts import assert_true from proboscis.asserts import fail -from proboscis.decorators import expect_exception -from proboscis.decorators import time_out from trove import tests from trove.tests.api.databases import TestDatabases -from trove.tests.api.instances import GROUP_START from trove.tests.api.instances import instance_info from trove.tests import util -from trove.tests.util import skip_if_xml from trove.tests.util import test_config from trove.tests.api.databases import TestMysqlAccess -from urllib import quote GROUP = "dbaas.api.users" FAKE = test_config.values['fake_mode'] @@ -62,17 +55,20 @@ class TestUsers(object): created_users = [username, username1] system_users = ['root', 'debian_sys_maint'] - @before_class - def setUp(self): + def __init__(self): self.dbaas = util.create_dbaas_client(instance_info.user) self.dbaas_admin = util.create_dbaas_client(instance_info.admin_user) - databases = [{"name": self.db1, "charset": "latin2", + + @before_class + def setUp(self): + databases = [{"name": self.db1, "character_set": "latin2", "collate": "latin2_general_ci"}, {"name": self.db2}] try: self.dbaas.databases.create(instance_info.id, databases) - except exceptions.BadRequest: - pass # If the db already exists that's OK. + except exceptions.BadRequest as e: + if "Validation error" in e.message: + raise e if not FAKE: time.sleep(5) @@ -154,12 +150,10 @@ def test_create_users_list_system(self): #tests for users that should not be listed users = self.dbaas.users.list(instance_info.id) assert_equal(200, self.dbaas.last_http_code) - found = False for user in self.system_users: found = any(result.name == user for result in users) msg = "User '%s' SHOULD NOT BE found in result" % user assert_false(found, msg) - found = False @test(depends_on=[test_create_users_list], runs_after=[test_fails_when_creating_user_twice]) @@ -251,9 +245,8 @@ def check_database_for_user(self, user, password, dbs): @test def test_username_too_long(self): - users = [] - users.append({"name": "1233asdwer345tyg56", "password": self.password, - "database": self.db1}) + users = [{"name": "1233asdwer345tyg56", "password": self.password, + "database": self.db1}] assert_raises(exceptions.BadRequest, self.dbaas.users.create, instance_info.id, users) assert_equal(400, self.dbaas.last_http_code) @@ -287,9 +280,8 @@ def test_delete_user_with_period_in_name(self): @test def test_invalid_password(self): - users = [] - users.append({"name": "anouser", "password": "sdf,;", - "database": self.db1}) + users = [{"name": "anouser", "password": "sdf,;", + "database": self.db1}] assert_raises(exceptions.BadRequest, self.dbaas.users.create, instance_info.id, users) assert_equal(400, self.dbaas.last_http_code) diff --git a/trove/tests/unittests/backup/test_backup_controller.py b/trove/tests/unittests/backup/test_backup_controller.py new file mode 100644 index 0000000000..537650e0a1 --- /dev/null +++ b/trove/tests/unittests/backup/test_backup_controller.py @@ -0,0 +1,43 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# 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 jsonschema +from testtools import TestCase +from testtools.matchers import Equals +from trove.backup.service import BackupController +from trove.common import apischema + + +class TestBackupController(TestCase): + def test_validate_create_complete(self): + body = {"backup": {"instance": "d6338c9c-3cc8-4313-b98f-13cc0684cf15", + "name": "testback-backup"}} + controller = BackupController() + schema = controller.get_schema('create', body) + validator = jsonschema.Draft4Validator(schema) + self.assertTrue(validator.is_valid(body)) + + def test_validate_create_invalid_uuid(self): + invalid_uuid = "ead-edsa-e23-sdf-23" + body = {"backup": {"instance": invalid_uuid, + "name": "testback-backup"}} + controller = BackupController() + schema = controller.get_schema('create', body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.assertThat(errors[0].message, + Equals("'%s' does not match '%s'" % + (invalid_uuid, apischema.uuid['pattern']))) diff --git a/trove/tests/unittests/instance/__init__.py b/trove/tests/unittests/instance/__init__.py new file mode 100644 index 0000000000..487d0c8c67 --- /dev/null +++ b/trove/tests/unittests/instance/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# 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. +# diff --git a/trove/tests/unittests/instance/test_instance_controller.py b/trove/tests/unittests/instance/test_instance_controller.py new file mode 100644 index 0000000000..08aea6ccc9 --- /dev/null +++ b/trove/tests/unittests/instance/test_instance_controller.py @@ -0,0 +1,217 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# 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 jsonschema +from testtools import TestCase +from testtools.matchers import Is, Equals +from testtools.testcase import skip +from trove.common import apischema +from trove.instance.service import InstanceController + + +class TestInstanceController(TestCase): + def setUp(self): + super(TestInstanceController, self).setUp() + self.controller = InstanceController() + self.instance = { + "instance": { + "volume": {"size": "1"}, + "users": [ + {"name": "user1", + "password": "litepass", + "databases": [{"name": "firstdb"}]} + ], + "flavorRef": "https://localhost:8779/v1.0/2500/1", + "name": "TEST-XYS2d2fe2kl;zx;jkl2l;sjdcma239(E)@(D", + "databases": [ + { + "name": "firstdb", + "collate": "latin2_general_ci", + "character_set": "latin2" + }, + { + "name": "db2" + } + ] + } + } + + def verify_errors(self, errors, msg=None, properties=None, path=None): + msg = msg or [] + properties = properties or [] + self.assertThat(len(errors), Is(len(msg))) + i = 0 + while i < len(msg): + self.assertThat(errors[i].message, Equals(msg[i])) + if path: + self.assertThat(path, Equals(properties[i])) + else: + self.assertThat(errors[i].path.pop(), Equals(properties[i])) + i += 1 + + def test_get_schema_create(self): + schema = self.controller.get_schema('create', {'instance': {}}) + self.assertIsNotNone(schema) + self.assertTrue('instance' in schema['properties']) + + def test_get_schema_action_restart(self): + schema = self.controller.get_schema('action', {'restart': {}}) + self.assertIsNotNone(schema) + self.assertTrue('restart' in schema['properties']) + + def test_get_schema_action_resize_volume(self): + schema = self.controller.get_schema( + 'action', {'resize': {'volume': {}}}) + self.assertIsNotNone(schema) + self.assertTrue('resize' in schema['properties']) + self.assertTrue( + 'volume' in schema['properties']['resize']['properties']) + + def test_get_schema_action_resize_flavorRef(self): + schema = self.controller.get_schema( + 'action', {'resize': {'flavorRef': {}}}) + self.assertIsNotNone(schema) + self.assertTrue('resize' in schema['properties']) + self.assertTrue( + 'flavorRef' in schema['properties']['resize']['properties']) + + def test_get_schema_action_other(self): + schema = self.controller.get_schema( + 'action', {'supersized': {'flavorRef': {}}}) + self.assertIsNotNone(schema) + self.assertThat(len(schema.keys()), Is(0)) + + def test_validate_create_complete(self): + body = self.instance + schema = self.controller.get_schema('create', body) + validator = jsonschema.Draft4Validator(schema) + self.assertTrue(validator.is_valid(body)) + + def test_validate_create_complete_with_restore(self): + body = self.instance + body['instance']['restorePoint'] = { + "backupRef": "d761edd8-0771-46ff-9743-688b9e297a3b" + } + schema = self.controller.get_schema('create', body) + validator = jsonschema.Draft4Validator(schema) + self.assertTrue(validator.is_valid(body)) + + def test_validate_create_complete_with_restore(self): + body = self.instance + backup_id_ref = "invalid-backup-id-ref" + body['instance']['restorePoint'] = { + "backupRef": backup_id_ref + } + schema = self.controller.get_schema('create', body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.assertThat(len(errors), Is(1)) + self.assertThat(errors[0].message, + Equals("'%s' does not match '%s'" % + (backup_id_ref, apischema.uuid['pattern']))) + + def test_validate_create_blankname(self): + body = self.instance + body['instance']['name'] = " " + schema = self.controller.get_schema('create', body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.assertThat(len(errors), Is(1)) + self.assertThat(errors[0].message, + Equals("' ' does not match '^.*[0-9a-zA-Z]+.*$'")) + + def test_validate_restart(self): + body = {"restart": {}} + schema = self.controller.get_schema('action', body) + validator = jsonschema.Draft4Validator(schema) + self.assertTrue(validator.is_valid(body)) + + def test_validate_invalid_action(self): + # TODO(juice) perhaps we should validate the schema not recognized + body = {"restarted": {}} + schema = self.controller.get_schema('action', body) + validator = jsonschema.Draft4Validator(schema) + self.assertTrue(validator.is_valid(body)) + + def test_validate_resize_volume(self): + body = {"resize": {"volume": {"size": 4}}} + schema = self.controller.get_schema('action', body) + validator = jsonschema.Draft4Validator(schema) + self.assertTrue(validator.is_valid(body)) + + def test_validate_resize_volume_string(self): + body = {"resize": {"volume": {"size": '-44.0'}}} + schema = self.controller.get_schema('action', body) + validator = jsonschema.Draft4Validator(schema) + self.assertTrue(validator.is_valid(body)) + + def test_validate_resize_volume_invalid(self): + body = {"resize": {"volume": {"size": 'x'}}} + schema = self.controller.get_schema('action', body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.assertThat(errors[0].context[0].message, + Equals("'x' is not of type 'integer'")) + self.assertThat(errors[0].context[1].message, + Equals("'x' does not match '[0-9]+'")) + self.assertThat(errors[0].path.pop(), Equals('size')) + + def test_validate_resize_instance(self): + body = {"resize": {"flavorRef": "https://endpoint/v1.0/123/flavors/2"}} + schema = self.controller.get_schema('action', body) + validator = jsonschema.Draft4Validator(schema) + self.assertTrue(validator.is_valid(body)) + + def test_validate_resize_instance_int(self): + body = {"resize": {"flavorRef": 2}} + schema = self.controller.get_schema('action', body) + validator = jsonschema.Draft4Validator(schema) + self.assertTrue(validator.is_valid(body)) + + def test_validate_resize_instance_int_xml(self): + body = {"resize": {"flavorRef": "2"}} + schema = self.controller.get_schema('action', body) + validator = jsonschema.Draft4Validator(schema) + self.assertTrue(validator.is_valid(body)) + + def test_validate_resize_instance_empty_url(self): + body = {"resize": {"flavorRef": ""}} + schema = self.controller.get_schema('action', body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.verify_errors(errors[0].context, + ["'' is too short", + "'' does not match 'http[s]?://(?:[a-zA-Z]" + "|[0-9]|[$-_@.&+]|[!*\\\\(\\\\),]" + "|(?:%[0-9a-fA-F][0-9a-fA-F]))+'", + "'' does not match '[0-9]+'", + "'' is not of type 'integer'"], + ["flavorRef", "flavorRef", "flavorRef", + "flavorRef"], + errors[0].path.pop()) + + @skip("This damn URI validator allows just about any garbage you give it") + def test_validate_resize_instance_invalid_url(self): + body = {"resize": {"flavorRef": "xyz-re1f2-daze329d-f23901"}} + schema = self.controller.get_schema('action', body) + self.assertIsNotNone(schema) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.verify_errors(errors, ["'' is too short"], ["flavorRef"]) diff --git a/trove/tests/unittests/mysql/__init__.py b/trove/tests/unittests/mysql/__init__.py new file mode 100644 index 0000000000..487d0c8c67 --- /dev/null +++ b/trove/tests/unittests/mysql/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# 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. +# diff --git a/trove/tests/unittests/mysql/test_user_controller.py b/trove/tests/unittests/mysql/test_user_controller.py new file mode 100644 index 0000000000..1c4f2e90cc --- /dev/null +++ b/trove/tests/unittests/mysql/test_user_controller.py @@ -0,0 +1,252 @@ +# Copyright 2013 Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# 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 jsonschema + +from testtools import TestCase +from testtools.matchers import Is, Equals +from trove.extensions.mysql.service import UserController +from trove.extensions.mysql.service import UserAccessController +from trove.extensions.mysql.service import SchemaController + + +class TestUserController(TestCase): + def setUp(self): + super(TestUserController, self).setUp() + self.controller = UserController() + + def test_get_create_schema(self): + body = {'users': [{'name': 'test', 'password': 'test'}]} + schema = self.controller.get_schema('create', body) + self.assertTrue('users' in schema['properties']) + + def test_get_update_user_pw(self): + body = {'users': [{'name': 'test', 'password': 'test'}]} + schema = self.controller.get_schema('update', body) + self.assertTrue('users' in schema['properties']) + + def test_get_update_user_db(self): + body = {'databases': [{'name': 'test'}, {'name': 'test'}]} + schema = self.controller.get_schema('update', body) + self.assertTrue('databases' in schema['properties']) + + def test_validate_create_empty(self): + body = {"users": []} + schema = self.controller.get_schema('create', body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.assertThat(len(errors), Is(1)) + self.assertThat(errors[0].message, Equals("[] is too short")) + self.assertThat(errors[0].path.pop(), Equals("users")) + + def test_validate_create_short_password(self): + body = {"users": [{"name": "joe", "password": ""}]} + schema = self.controller.get_schema('create', body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.assertThat(len(errors), Is(2)) + self.assertThat(errors[0].message, Equals("'' is too short")) + self.assertThat(errors[1].message, + Equals("'' does not match '^.*[0-9a-zA-Z]+.*$'")) + self.assertThat(errors[0].path.pop(), Equals("password")) + + def test_validate_create_no_password(self): + body = {"users": [{"name": "joe"}]} + schema = self.controller.get_schema('create', body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.assertThat(len(errors), Is(1)) + self.assertThat(errors[0].message, + Equals("'password' is a required property")) + + def test_validate_create_short_name(self): + body = {"users": [{"name": ""}]} + schema = self.controller.get_schema('create', body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.assertThat(len(errors), Is(3)) + self.assertThat(errors[0].message, + Equals("'password' is a required property")) + self.assertThat(errors[1].message, Equals("'' is too short")) + self.assertThat(errors[2].message, + Equals("'' does not match '^.*[0-9a-zA-Z]+.*$'")) + self.assertThat(errors[1].path.pop(), Equals("name")) + + def test_validate_create_complete_db_empty(self): + body = {"users": [{"databases": [], "name": "joe", "password": "123"}]} + schema = self.controller.get_schema('create', body) + validator = jsonschema.Draft4Validator(schema) + self.assertTrue(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.assertThat(len(errors), Is(0)) + + def test_validate_create_complete_db_no_name(self): + body = {"users": [{"databases": [{}], "name": "joe", + "password": "123"}]} + schema = self.controller.get_schema('create', body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.assertThat(len(errors), Is(1)) + self.assertThat(errors[0].message, + Equals("'name' is a required property")) + + def test_validate_create_complete_db(self): + body = {"users": [{"databases": [{"name": "x"}], "name": "joe", + "password": "123"}]} + schema = self.controller.get_schema('create', body) + validator = jsonschema.Draft4Validator(schema) + self.assertTrue(validator.is_valid(body)) + + def test_validate_update_empty(self): + body = {"users": []} + schema = self.controller.get_schema('update', body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.assertThat(len(errors), Is(1)) + self.assertThat(errors[0].message, Equals("[] is too short")) + self.assertThat(errors[0].path.pop(), Equals("users")) + + def test_validate_update_short_password(self): + body = {"users": [{"name": "joe", "password": ""}]} + schema = self.controller.get_schema('update', body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.assertThat(len(errors), Is(2)) + self.assertThat(errors[0].message, Equals("'' is too short")) + self.assertThat(errors[1].message, + Equals("'' does not match '^.*[0-9a-zA-Z]+.*$'")) + self.assertThat(errors[0].path.pop(), Equals("password")) + + def test_validate_update_user_complete(self): + body = {"users": [{"name": "joe", "password": "", + "databases": [{"name": "testdb"}]}]} + schema = self.controller.get_schema('update', body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.assertThat(len(errors), Is(2)) + self.assertThat(errors[0].message, Equals("'' is too short")) + self.assertThat(errors[1].message, Equals( + "'' does not match '^.*[0-9a-zA-Z]+.*$'")) + self.assertThat(errors[0].path.pop(), Equals("password")) + + def test_validate_update_user_with_db_short_password(self): + body = {"users": [{"name": "joe", "password": "", + "databases": [{"name": "testdb"}]}]} + schema = self.controller.get_schema('update', body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.assertThat(len(errors), Is(2)) + self.assertThat(errors[0].message, Equals("'' is too short")) + self.assertThat(errors[0].path.pop(), Equals("password")) + + def test_validate_update_no_password(self): + body = {"users": [{"name": "joe"}]} + schema = self.controller.get_schema('update', body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.assertThat(len(errors), Is(1)) + self.assertThat(errors[0].message, + Equals("'password' is a required property")) + + def test_validate_update_database_complete(self): + body = {"databases": [{"name": "test1"}, {"name": "test2"}]} + schema = self.controller.get_schema('update', body) + validator = jsonschema.Draft4Validator(schema) + self.assertTrue(validator.is_valid(body)) + + def test_validate_update_database_empty(self): + body = {"databases": []} + schema = self.controller.get_schema('update', body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.assertThat(len(errors), Is(1)) + self.assertThat(errors[0].message, Equals('[] is too short')) + + def test_validate_update_short_name(self): + body = {"users": [{"name": ""}]} + schema = self.controller.get_schema('update', body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.assertThat(len(errors), Is(3)) + self.assertThat(errors[0].message, + Equals("'password' is a required property")) + self.assertThat(errors[1].message, Equals("'' is too short")) + self.assertThat(errors[2].message, + Equals("'' does not match '^.*[0-9a-zA-Z]+.*$'")) + self.assertThat(errors[1].path.pop(), Equals("name")) + + +class TestUserAccessController(TestCase): + def test_validate_update_db(self): + body = {"databases": []} + schema = (UserAccessController()).get_schema('update', body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + errors = sorted(validator.iter_errors(body), key=lambda e: e.path) + self.assertThat(len(errors), Is(1)) + self.assertThat(errors[0].message, Equals("[] is too short")) + self.assertThat(errors[0].path.pop(), Equals("databases")) + + +class TestSchemaController(TestCase): + def setUp(self): + super(TestSchemaController, self).setUp() + self.controller = SchemaController() + self.body = { + "databases": [ + { + "name": "first_db", + "collate": "latin2_general_ci", + "character_set": "latin2" + }, + { + "name": "second_db" + } + ] + } + + def test_validate_mixed(self): + schema = self.controller.get_schema('create', self.body) + self.assertIsNotNone(schema) + validator = jsonschema.Draft4Validator(schema) + self.assertTrue(validator.is_valid(self.body)) + + def test_validate_mixed_with_no_name(self): + body = self.body.copy() + body['databases'].append({"collate": "some_collation"}) + schema = self.controller.get_schema('create', body) + self.assertIsNotNone(schema) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) + + def test_validate_empty(self): + body = {"databases": []} + schema = self.controller.get_schema('create', body) + self.assertIsNotNone(schema) + self.assertTrue('databases' in body) + validator = jsonschema.Draft4Validator(schema) + self.assertFalse(validator.is_valid(body)) diff --git a/trove/tests/util/__init__.py b/trove/tests/util/__init__.py index 9617a4a8a8..5128d88094 100644 --- a/trove/tests/util/__init__.py +++ b/trove/tests/util/__init__.py @@ -26,15 +26,12 @@ .. moduleauthor:: Tim Simpson """ -import re import subprocess -import sys import time from trove.tests.config import CONFIG as test_config from urllib import unquote - try: from eventlet import event from eventlet import greenthread @@ -46,13 +43,8 @@ from troveclient import exceptions -from proboscis import test -from proboscis.asserts import assert_false -from proboscis.asserts import assert_raises -from proboscis.asserts import assert_true from proboscis.asserts import Check from proboscis.asserts import fail -from proboscis.asserts import ASSERTION_ERROR from proboscis import SkipTest from troveclient import Dbaas from troveclient.client import TroveHTTPClient