diff options
author | Ken'ichi Ohmichi <oomichi@mxs.nes.nec.co.jp> | 2015-01-15 10:50:21 +0000 |
---|---|---|
committer | Ken'ichi Ohmichi <oomichi@mxs.nes.nec.co.jp> | 2015-01-16 04:40:00 +0000 |
commit | 84dee6b7818c2c755b8338c527b0059f74f9f07a (patch) | |
tree | 9ed2619a7f7812205baa3b362f46872a007083ee | |
parent | d588748a36a36701d69f40b68bf3d62f20d76baf (diff) | |
download | tempest-lib-84dee6b7818c2c755b8338c527b0059f74f9f07a.tar.gz |
Migrate rest_client to tempest-lib from tempest0.1.0
This patch migrates rest_client module to tempest-lib from tempest.
The latest Change-Ids of each file are the following when this migration:
* common/http.py : I43703e2289212389c7841f44691ae7849ed1f505
* common/rest_client.py : Ie9105b5d01e7883213c1d3398cc5fe56782920d9
* common/utils/misc.py : I9a591eaa1cf4dabba58f06a64814611a05a51365
* exceptions.py : Ic8fc216377942619f11a2462b79d0597071ac294
* tests/base.py : I8f14cd2ca6afc38d3fe8ee758272071111022896
* tests/fake_auth_provider.py: Id12341de52204e2c428e10b4b758b700b0fbab09
* tests/fake_http.py : I8f14cd2ca6afc38d3fe8ee758272071111022896
* tests/test_rest_client.py : Ie9105b5d01e7883213c1d3398cc5fe56782920d9
NOTE: Some docstrings are changed to avoid H404 and H405.
Change-Id: I879a02681c99376ae57458a0f7a04c8032dfebb2
-rw-r--r-- | requirements.txt | 3 | ||||
-rw-r--r-- | tempest_lib/common/__init__.py | 0 | ||||
-rw-r--r-- | tempest_lib/common/http.py | 25 | ||||
-rw-r--r-- | tempest_lib/common/rest_client.py | 561 | ||||
-rw-r--r-- | tempest_lib/common/utils/__init__.py | 0 | ||||
-rw-r--r-- | tempest_lib/common/utils/misc.py | 87 | ||||
-rw-r--r-- | tempest_lib/exceptions.py | 15 | ||||
-rw-r--r-- | tempest_lib/tests/base.py | 79 | ||||
-rw-r--r-- | tempest_lib/tests/fake_auth_provider.py | 20 | ||||
-rw-r--r-- | tempest_lib/tests/fake_http.py | 74 | ||||
-rw-r--r-- | tempest_lib/tests/test_rest_client.py | 470 | ||||
-rw-r--r-- | test-requirements.txt | 1 |
12 files changed, 1285 insertions, 50 deletions
diff --git a/requirements.txt b/requirements.txt index 96378b6..e7e97ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,6 @@ Babel>=1.3 fixtures>=0.3.14 oslo.config>=1.4.0 # Apache-2.0 iso8601>=0.1.9 +jsonschema>=2.0.0,<3.0.0 +httplib2>=0.7.5 +six>=1.7.0 diff --git a/tempest_lib/common/__init__.py b/tempest_lib/common/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tempest_lib/common/__init__.py diff --git a/tempest_lib/common/http.py b/tempest_lib/common/http.py new file mode 100644 index 0000000..b3793bc --- /dev/null +++ b/tempest_lib/common/http.py @@ -0,0 +1,25 @@ +# Copyright 2013 OpenStack Foundation +# Copyright 2013 Citrix Systems, Inc. +# 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 httplib2 + + +class ClosingHttp(httplib2.Http): + def request(self, *args, **kwargs): + original_headers = kwargs.get('headers', {}) + new_headers = dict(original_headers, connection='close') + new_kwargs = dict(kwargs, headers=new_headers) + return super(ClosingHttp, self).request(*args, **new_kwargs) diff --git a/tempest_lib/common/rest_client.py b/tempest_lib/common/rest_client.py new file mode 100644 index 0000000..683efa5 --- /dev/null +++ b/tempest_lib/common/rest_client.py @@ -0,0 +1,561 @@ +# Copyright 2012 OpenStack Foundation +# Copyright 2013 IBM Corp. +# 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 collections +import json +import logging as real_logging +import re +import time + +import jsonschema +import six + +from tempest_lib.common import http +from tempest_lib.common.utils import misc as misc_utils +from tempest_lib import exceptions +from tempest_lib.openstack.common import log as logging + +# redrive rate limited calls at most twice +MAX_RECURSION_DEPTH = 2 + +# All the successful HTTP status codes from RFC 7231 & 4918 +HTTP_SUCCESS = (200, 201, 202, 203, 204, 205, 206, 207) + + +class RestClient(object): + + TYPE = "json" + + LOG = logging.getLogger(__name__) + + def __init__(self, auth_provider, service, region, + endpoint_type='publicURL', + build_interval=1, build_timeout=60, + disable_ssl_certificate_validation=False, ca_certs=None, + trace_requests=''): + self.auth_provider = auth_provider + self.service = service + self.region = region + self.endpoint_type = endpoint_type + self.build_interval = build_interval + self.build_timeout = build_timeout + self.trace_requests = trace_requests + + # The version of the API this client implements + self.api_version = None + self._skip_path = False + self.general_header_lc = set(('cache-control', 'connection', + 'date', 'pragma', 'trailer', + 'transfer-encoding', 'via', + 'warning')) + self.response_header_lc = set(('accept-ranges', 'age', 'etag', + 'location', 'proxy-authenticate', + 'retry-after', 'server', + 'vary', 'www-authenticate')) + dscv = disable_ssl_certificate_validation + self.http_obj = http.ClosingHttp( + disable_ssl_certificate_validation=dscv, ca_certs=ca_certs) + + def _get_type(self): + return self.TYPE + + def get_headers(self, accept_type=None, send_type=None): + if accept_type is None: + accept_type = self._get_type() + if send_type is None: + send_type = self._get_type() + return {'Content-Type': 'application/%s' % send_type, + 'Accept': 'application/%s' % accept_type} + + def __str__(self): + STRING_LIMIT = 80 + str_format = ("service:%s, base_url:%s, " + "filters: %s, build_interval:%s, build_timeout:%s" + "\ntoken:%s..., \nheaders:%s...") + return str_format % (self.service, self.base_url, + self.filters, self.build_interval, + self.build_timeout, + str(self.token)[0:STRING_LIMIT], + str(self.get_headers())[0:STRING_LIMIT]) + + @property + def user(self): + return self.auth_provider.credentials.username + + @property + def user_id(self): + return self.auth_provider.credentials.user_id + + @property + def tenant_name(self): + return self.auth_provider.credentials.tenant_name + + @property + def tenant_id(self): + return self.auth_provider.credentials.tenant_id + + @property + def password(self): + return self.auth_provider.credentials.password + + @property + def base_url(self): + return self.auth_provider.base_url(filters=self.filters) + + @property + def token(self): + return self.auth_provider.get_token() + + @property + def filters(self): + _filters = dict( + service=self.service, + endpoint_type=self.endpoint_type, + region=self.region + ) + if self.api_version is not None: + _filters['api_version'] = self.api_version + if self._skip_path: + _filters['skip_path'] = self._skip_path + return _filters + + def skip_path(self): + """When set, ignore the path part of the base URL from the catalog""" + self._skip_path = True + + def reset_path(self): + """When reset, use the base URL from the catalog as-is""" + self._skip_path = False + + @classmethod + def expected_success(cls, expected_code, read_code): + assert_msg = ("This function only allowed to use for HTTP status" + "codes which explicitly defined in the RFC 7231 & 4918." + "{0} is not a defined Success Code!" + ).format(expected_code) + if isinstance(expected_code, list): + for code in expected_code: + assert code in HTTP_SUCCESS, assert_msg + else: + assert expected_code in HTTP_SUCCESS, assert_msg + + # NOTE(afazekas): the http status code above 400 is processed by + # the _error_checker method + if read_code < 400: + pattern = """Unexpected http success status code {0}, + The expected status code is {1}""" + if ((not isinstance(expected_code, list) and + (read_code != expected_code)) or + (isinstance(expected_code, list) and + (read_code not in expected_code))): + details = pattern.format(read_code, expected_code) + raise exceptions.InvalidHttpSuccessCode(details) + + def post(self, url, body, headers=None, extra_headers=False): + return self.request('POST', url, extra_headers, headers, body) + + def get(self, url, headers=None, extra_headers=False): + return self.request('GET', url, extra_headers, headers) + + def delete(self, url, headers=None, body=None, extra_headers=False): + return self.request('DELETE', url, extra_headers, headers, body) + + def patch(self, url, body, headers=None, extra_headers=False): + return self.request('PATCH', url, extra_headers, headers, body) + + def put(self, url, body, headers=None, extra_headers=False): + return self.request('PUT', url, extra_headers, headers, body) + + def head(self, url, headers=None, extra_headers=False): + return self.request('HEAD', url, extra_headers, headers) + + def copy(self, url, headers=None, extra_headers=False): + return self.request('COPY', url, extra_headers, headers) + + def get_versions(self): + resp, body = self.get('') + body = self._parse_resp(body) + versions = map(lambda x: x['id'], body) + return resp, versions + + def _get_request_id(self, resp): + for i in ('x-openstack-request-id', 'x-compute-request-id'): + if i in resp: + return resp[i] + return "" + + def _safe_body(self, body, maxlen=4096): + # convert a structure into a string safely + try: + text = six.text_type(body) + except UnicodeDecodeError: + # if this isn't actually text, return marker that + return "<BinaryData: removed>" + if len(text) > maxlen: + return text[:maxlen] + else: + return text + + def _log_request_start(self, method, req_url, req_headers=None, + req_body=None): + if req_headers is None: + req_headers = {} + caller_name = misc_utils.find_test_caller() + if self.trace_requests and re.search(self.trace_requests, caller_name): + self.LOG.debug('Starting Request (%s): %s %s' % + (caller_name, method, req_url)) + + def _log_request_full(self, method, req_url, resp, + secs="", req_headers=None, + req_body=None, resp_body=None, + caller_name=None, extra=None): + if 'X-Auth-Token' in req_headers: + req_headers['X-Auth-Token'] = '<omitted>' + log_fmt = """Request (%s): %s %s %s%s + Request - Headers: %s + Body: %s + Response - Headers: %s + Body: %s""" + + self.LOG.debug( + log_fmt % ( + caller_name, + resp['status'], + method, + req_url, + secs, + str(req_headers), + self._safe_body(req_body), + str(resp), + self._safe_body(resp_body)), + extra=extra) + + def _log_request(self, method, req_url, resp, + secs="", req_headers=None, + req_body=None, resp_body=None): + if req_headers is None: + req_headers = {} + # if we have the request id, put it in the right part of the log + extra = dict(request_id=self._get_request_id(resp)) + # NOTE(sdague): while we still have 6 callers to this function + # we're going to just provide work around on who is actually + # providing timings by gracefully adding no content if they don't. + # Once we're down to 1 caller, clean this up. + caller_name = misc_utils.find_test_caller() + if secs: + secs = " %.3fs" % secs + if not self.LOG.isEnabledFor(real_logging.DEBUG): + self.LOG.info( + 'Request (%s): %s %s %s%s' % ( + caller_name, + resp['status'], + method, + req_url, + secs), + extra=extra) + + # Also look everything at DEBUG if you want to filter this + # out, don't run at debug. + self._log_request_full(method, req_url, resp, secs, req_headers, + req_body, resp_body, caller_name, extra) + + def _parse_resp(self, body): + body = json.loads(body) + + # We assume, that if the first value of the deserialized body's + # item set is a dict or a list, that we just return the first value + # of deserialized body. + # Essentially "cutting out" the first placeholder element in a body + # that looks like this: + # + # { + # "users": [ + # ... + # ] + # } + try: + # Ensure there are not more than one top-level keys + if len(body.keys()) > 1: + return body + # Just return the "wrapped" element + first_key, first_item = six.next(six.iteritems(body)) + if isinstance(first_item, (dict, list)): + return first_item + except (ValueError, IndexError): + pass + return body + + def response_checker(self, method, resp, resp_body): + if (resp.status in set((204, 205, 304)) or resp.status < 200 or + method.upper() == 'HEAD') and resp_body: + raise exceptions.ResponseWithNonEmptyBody(status=resp.status) + # NOTE(afazekas): + # If the HTTP Status Code is 205 + # 'The response MUST NOT include an entity.' + # A HTTP entity has an entity-body and an 'entity-header'. + # In the HTTP response specification (Section 6) the 'entity-header' + # 'generic-header' and 'response-header' are in OR relation. + # All headers not in the above two group are considered as entity + # header in every interpretation. + + if (resp.status == 205 and + 0 != len(set(resp.keys()) - set(('status',)) - + self.response_header_lc - self.general_header_lc)): + raise exceptions.ResponseWithEntity() + # NOTE(afazekas) + # Now the swift sometimes (delete not empty container) + # returns with non json error response, we can create new rest class + # for swift. + # Usually RFC2616 says error responses SHOULD contain an explanation. + # The warning is normal for SHOULD/SHOULD NOT case + + # Likely it will cause an error + if method != 'HEAD' and not resp_body and resp.status >= 400: + self.LOG.warning("status >= 400 response with empty body") + + def _request(self, method, url, headers=None, body=None): + """A simple HTTP request interface.""" + # Authenticate the request with the auth provider + req_url, req_headers, req_body = self.auth_provider.auth_request( + method, url, headers, body, self.filters) + + # Do the actual request, and time it + start = time.time() + self._log_request_start(method, req_url) + resp, resp_body = self.raw_request( + req_url, method, headers=req_headers, body=req_body) + end = time.time() + self._log_request(method, req_url, resp, secs=(end - start), + req_headers=req_headers, req_body=req_body, + resp_body=resp_body) + + # Verify HTTP response codes + self.response_checker(method, resp, resp_body) + + return resp, resp_body + + def raw_request(self, url, method, headers=None, body=None): + if headers is None: + headers = self.get_headers() + return self.http_obj.request(url, method, + headers=headers, body=body) + + def request(self, method, url, extra_headers=False, headers=None, + body=None): + # if extra_headers is True + # default headers would be added to headers + retry = 0 + + if headers is None: + # NOTE(vponomaryov): if some client do not need headers, + # it should explicitly pass empty dict + headers = self.get_headers() + elif extra_headers: + try: + headers = headers.copy() + headers.update(self.get_headers()) + except (ValueError, TypeError): + headers = self.get_headers() + + resp, resp_body = self._request(method, url, + headers=headers, body=body) + + while (resp.status == 413 and + 'retry-after' in resp and + not self.is_absolute_limit( + resp, self._parse_resp(resp_body)) and + retry < MAX_RECURSION_DEPTH): + retry += 1 + delay = int(resp['retry-after']) + time.sleep(delay) + resp, resp_body = self._request(method, url, + headers=headers, body=body) + self._error_checker(method, url, headers, body, + resp, resp_body) + return resp, resp_body + + def _error_checker(self, method, url, + headers, body, resp, resp_body): + + # NOTE(mtreinish): Check for httplib response from glance_http. The + # object can't be used here because importing httplib breaks httplib2. + # If another object from a class not imported were passed here as + # resp this could possibly fail + if str(type(resp)) == "<type 'instance'>": + ctype = resp.getheader('content-type') + else: + try: + ctype = resp['content-type'] + # NOTE(mtreinish): Keystone delete user responses doesn't have a + # content-type header. (They don't have a body) So just pretend it + # is set. + except KeyError: + ctype = 'application/json' + + # It is not an error response + if resp.status < 400: + return + + JSON_ENC = ['application/json', 'application/json; charset=utf-8'] + # NOTE(mtreinish): This is for compatibility with Glance and swift + # APIs. These are the return content types that Glance api v1 + # (and occasionally swift) are using. + TXT_ENC = ['text/plain', 'text/html', 'text/html; charset=utf-8', + 'text/plain; charset=utf-8'] + + if ctype.lower() in JSON_ENC: + parse_resp = True + elif ctype.lower() in TXT_ENC: + parse_resp = False + else: + raise exceptions.InvalidContentType(str(resp.status)) + + if resp.status == 401 or resp.status == 403: + raise exceptions.Unauthorized(resp_body) + + if resp.status == 404: + raise exceptions.NotFound(resp_body) + + if resp.status == 400: + if parse_resp: + resp_body = self._parse_resp(resp_body) + raise exceptions.BadRequest(resp_body) + + if resp.status == 409: + if parse_resp: + resp_body = self._parse_resp(resp_body) + raise exceptions.Conflict(resp_body) + + if resp.status == 413: + if parse_resp: + resp_body = self._parse_resp(resp_body) + if self.is_absolute_limit(resp, resp_body): + raise exceptions.OverLimit(resp_body) + else: + raise exceptions.RateLimitExceeded(resp_body) + + if resp.status == 415: + if parse_resp: + resp_body = self._parse_resp(resp_body) + raise exceptions.InvalidContentType(resp_body) + + if resp.status == 422: + if parse_resp: + resp_body = self._parse_resp(resp_body) + raise exceptions.UnprocessableEntity(resp_body) + + if resp.status in (500, 501): + message = resp_body + if parse_resp: + try: + resp_body = self._parse_resp(resp_body) + except ValueError: + # If response body is a non-json string message. + # Use resp_body as is and raise InvalidResponseBody + # exception. + raise exceptions.InvalidHTTPResponseBody(message) + else: + if isinstance(resp_body, dict): + # I'm seeing both computeFault + # and cloudServersFault come back. + # Will file a bug to fix, but leave as is for now. + if 'cloudServersFault' in resp_body: + message = resp_body['cloudServersFault']['message'] + elif 'computeFault' in resp_body: + message = resp_body['computeFault']['message'] + elif 'error' in resp_body: + message = resp_body['error']['message'] + elif 'message' in resp_body: + message = resp_body['message'] + else: + message = resp_body + + if resp.status == 501: + raise exceptions.NotImplemented(message) + else: + raise exceptions.ServerFault(message) + + if resp.status >= 400: + raise exceptions.UnexpectedResponseCode(str(resp.status)) + + def is_absolute_limit(self, resp, resp_body): + if (not isinstance(resp_body, collections.Mapping) or + 'retry-after' not in resp): + return True + over_limit = resp_body.get('overLimit', None) + if not over_limit: + return True + return 'exceed' in over_limit.get('message', 'blabla') + + def wait_for_resource_deletion(self, id): + """Waits for a resource to be deleted.""" + start_time = int(time.time()) + while True: + if self.is_resource_deleted(id): + return + if int(time.time()) - start_time >= self.build_timeout: + message = ('Failed to delete %(resource_type)s %(id)s within ' + 'the required time (%(timeout)s s).' % + {'resource_type': self.resource_type, 'id': id, + 'timeout': self.build_timeout}) + caller = misc_utils.find_test_caller() + if caller: + message = '(%s) %s' % (caller, message) + raise exceptions.TimeoutException(message) + time.sleep(self.build_interval) + + def is_resource_deleted(self, id): + """Subclasses override with specific deletion detection.""" + message = ('"%s" does not implement is_resource_deleted' + % self.__class__.__name__) + raise NotImplementedError(message) + + @property + def resource_type(self): + """Returns the primary type of resource this client works with.""" + return 'resource' + + @classmethod + def validate_response(cls, schema, resp, body): + # Only check the response if the status code is a success code + # TODO(cyeoh): Eventually we should be able to verify that a failure + # code if it exists is something that we expect. This is explicitly + # declared in the V3 API and so we should be able to export this in + # the response schema. For now we'll ignore it. + if resp.status in HTTP_SUCCESS: + cls.expected_success(schema['status_code'], resp.status) + + # Check the body of a response + body_schema = schema.get('response_body') + if body_schema: + try: + jsonschema.validate(body, body_schema) + except jsonschema.ValidationError as ex: + msg = ("HTTP response body is invalid (%s)") % ex + raise exceptions.InvalidHTTPResponseBody(msg) + else: + if body: + msg = ("HTTP response body should not exist (%s)") % body + raise exceptions.InvalidHTTPResponseBody(msg) + + # Check the header of a response + header_schema = schema.get('response_header') + if header_schema: + try: + jsonschema.validate(resp, header_schema) + except jsonschema.ValidationError as ex: + msg = ("HTTP response header is invalid (%s)") % ex + raise exceptions.InvalidHTTPResponseHeader(msg) diff --git a/tempest_lib/common/utils/__init__.py b/tempest_lib/common/utils/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tempest_lib/common/utils/__init__.py diff --git a/tempest_lib/common/utils/misc.py b/tempest_lib/common/utils/misc.py new file mode 100644 index 0000000..874dece --- /dev/null +++ b/tempest_lib/common/utils/misc.py @@ -0,0 +1,87 @@ +# Copyright 2012 OpenStack Foundation +# 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 inspect +import re + +from tempest_lib.openstack.common import log as logging + +LOG = logging.getLogger(__name__) + + +def singleton(cls): + """Simple wrapper for classes that should only have a single instance.""" + instances = {} + + def getinstance(): + if cls not in instances: + instances[cls] = cls() + return instances[cls] + return getinstance + + +def find_test_caller(): + """Find the caller class and test name. + + Because we know that the interesting things that call us are + test_* methods, and various kinds of setUp / tearDown, we + can look through the call stack to find appropriate methods, + and the class we were in when those were called. + """ + caller_name = None + names = [] + frame = inspect.currentframe() + is_cleanup = False + # Start climbing the ladder until we hit a good method + while True: + try: + frame = frame.f_back + name = frame.f_code.co_name + names.append(name) + if re.search("^(test_|setUp|tearDown)", name): + cname = "" + if 'self' in frame.f_locals: + cname = frame.f_locals['self'].__class__.__name__ + if 'cls' in frame.f_locals: + cname = frame.f_locals['cls'].__name__ + caller_name = cname + ":" + name + break + elif re.search("^_run_cleanup", name): + is_cleanup = True + elif name == 'main': + caller_name = 'main' + break + else: + cname = "" + if 'self' in frame.f_locals: + cname = frame.f_locals['self'].__class__.__name__ + if 'cls' in frame.f_locals: + cname = frame.f_locals['cls'].__name__ + + # the fact that we are running cleanups is indicated pretty + # deep in the stack, so if we see that we want to just + # start looking for a real class name, and declare victory + # once we do. + if is_cleanup and cname: + if not re.search("^RunTest", cname): + caller_name = cname + ":_run_cleanups" + break + except Exception: + break + # prevents frame leaks + del frame + if caller_name is None: + LOG.debug("Sane call name not found in %s" % names) + return caller_name diff --git a/tempest_lib/exceptions.py b/tempest_lib/exceptions.py index 0c5a1ef..69aaf2a 100644 --- a/tempest_lib/exceptions.py +++ b/tempest_lib/exceptions.py @@ -17,13 +17,12 @@ import testtools class TempestException(Exception): - """Base Tempest Exception. + """Base Tempest Exception To correctly use this class, inherit from it and define a 'message' property. That message will get printf'd with the keyword arguments provided to the constructor. """ - message = "An unknown exception occurred" def __init__(self, *args, **kwargs): @@ -75,7 +74,7 @@ class Unauthorized(RestClientException): message = 'Unauthorized' -class InvalidServiceTag(RestClientException): +class InvalidServiceTag(TempestException): message = "Invalid service tag" @@ -140,18 +139,22 @@ class EndpointNotFound(TempestException): message = "Endpoint not found" -class RateLimitExceeded(TempestException): +class RateLimitExceeded(RestClientException): message = "Rate limit exceeded" -class OverLimit(TempestException): +class OverLimit(RestClientException): message = "Quota exceeded" -class ServerFault(TempestException): +class ServerFault(RestClientException): message = "Got server fault" +class NotImplemented(RestClientException): + message = "Got NotImplemented error" + + class ImageFault(TempestException): message = "Got image fault" diff --git a/tempest_lib/tests/base.py b/tempest_lib/tests/base.py index 3bf12a8..fe9268e 100644 --- a/tempest_lib/tests/base.py +++ b/tempest_lib/tests/base.py @@ -1,53 +1,44 @@ -# -*- coding: utf-8 -*- - -# Copyright 2010-2011 OpenStack Foundation -# Copyright (c) 2013 Hewlett-Packard Development Company, L.P. +# Copyright 2013 IBM Corp. # -# 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 +# 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 +# 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 os - -import fixtures -import testtools +# 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. -_TRUE_VALUES = ('True', 'true', '1', 'yes') +import mock +from oslotest import base +from oslotest import moxstubout -class TestCase(testtools.TestCase): - - """Test case base class for all unit tests.""" +class TestCase(base.BaseTestCase): def setUp(self): - """Run before each test method to initialize test environment.""" - super(TestCase, self).setUp() - test_timeout = os.environ.get('OS_TEST_TIMEOUT', 0) - try: - test_timeout = int(test_timeout) - except ValueError: - # If timeout value is invalid do not set a timeout. - test_timeout = 0 - if test_timeout > 0: - self.useFixture(fixtures.Timeout(test_timeout, gentle=True)) - - self.useFixture(fixtures.NestedTempfile()) - self.useFixture(fixtures.TempHomeDir()) - - if os.environ.get('OS_STDOUT_CAPTURE') in _TRUE_VALUES: - stdout = self.useFixture(fixtures.StringStream('stdout')).stream - self.useFixture(fixtures.MonkeyPatch('sys.stdout', stdout)) - if os.environ.get('OS_STDERR_CAPTURE') in _TRUE_VALUES: - stderr = self.useFixture(fixtures.StringStream('stderr')).stream - self.useFixture(fixtures.MonkeyPatch('sys.stderr', stderr)) - - self.log_fixture = self.useFixture(fixtures.FakeLogger())
\ No newline at end of file + mox_fixture = self.useFixture(moxstubout.MoxStubout()) + self.mox = mox_fixture.mox + self.stubs = mox_fixture.stubs + + def patch(self, target, **kwargs): + """Returns a started `mock.patch` object for the supplied target. + + The caller may then call the returned patcher to create a mock object. + + The caller does not need to call stop() on the returned + patcher object, as this method automatically adds a cleanup + to the test class to stop the patcher. + + :param target: String module.class or module.object expression to patch + :param **kwargs: Passed as-is to `mock.patch`. See mock documentation + for details. + """ + p = mock.patch(target, **kwargs) + m = p.start() + self.addCleanup(p.stop) + return m diff --git a/tempest_lib/tests/fake_auth_provider.py b/tempest_lib/tests/fake_auth_provider.py new file mode 100644 index 0000000..bc68d26 --- /dev/null +++ b/tempest_lib/tests/fake_auth_provider.py @@ -0,0 +1,20 @@ +# Copyright 2014 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. + + +class FakeAuthProvider(object): + + def auth_request(self, method, url, headers=None, body=None, filters=None): + return url, headers, body diff --git a/tempest_lib/tests/fake_http.py b/tempest_lib/tests/fake_http.py new file mode 100644 index 0000000..e14b25c --- /dev/null +++ b/tempest_lib/tests/fake_http.py @@ -0,0 +1,74 @@ +# Copyright 2013 IBM Corp. +# +# 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 copy + +import httplib2 + + +class fake_httplib2(object): + + def __init__(self, return_type=None, *args, **kwargs): + self.return_type = return_type + + def request(self, uri, method="GET", body=None, headers=None, + redirections=5, connection_type=None): + if not self.return_type: + fake_headers = httplib2.Response(headers) + return_obj = { + 'uri': uri, + 'method': method, + 'body': body, + 'headers': headers + } + return (fake_headers, return_obj) + elif isinstance(self.return_type, int): + body = "fake_body" + header_info = { + 'content-type': 'text/plain', + 'status': str(self.return_type), + 'content-length': len(body) + } + resp_header = httplib2.Response(header_info) + return (resp_header, body) + else: + msg = "unsupported return type %s" % self.return_type + raise TypeError(msg) + + +class fake_httplib(object): + def __init__(self, headers, body=None, + version=1.0, status=200, reason="Ok"): + """fake_httplib for tests + + :param headers: dict representing HTTP response headers + :param body: file-like object + :param version: HTTP Version + :param status: Response status code + :param reason: Status code related message. + """ + self.body = body + self.status = status + self.reason = reason + self.version = version + self.headers = headers + + def getheaders(self): + return copy.deepcopy(self.headers).items() + + def getheader(self, key, default): + return self.headers.get(key, default) + + def read(self, amt): + return self.body.read(amt) diff --git a/tempest_lib/tests/test_rest_client.py b/tempest_lib/tests/test_rest_client.py new file mode 100644 index 0000000..5721b18 --- /dev/null +++ b/tempest_lib/tests/test_rest_client.py @@ -0,0 +1,470 @@ +# Copyright 2013 IBM Corp. +# +# 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 json + +import httplib2 +from oslotest import mockpatch +import six + +from tempest_lib.common import rest_client +from tempest_lib import exceptions +from tempest_lib.tests import base +from tempest_lib.tests import fake_auth_provider +from tempest_lib.tests import fake_http + + +class BaseRestClientTestClass(base.TestCase): + + url = 'fake_endpoint' + + def setUp(self): + super(BaseRestClientTestClass, self).setUp() + self.rest_client = rest_client.RestClient( + fake_auth_provider.FakeAuthProvider(), None, None) + self.stubs.Set(httplib2.Http, 'request', self.fake_http.request) + self.useFixture(mockpatch.PatchObject(self.rest_client, + '_log_request')) + + +class TestRestClientHTTPMethods(BaseRestClientTestClass): + def setUp(self): + self.fake_http = fake_http.fake_httplib2() + super(TestRestClientHTTPMethods, self).setUp() + self.useFixture(mockpatch.PatchObject(self.rest_client, + '_error_checker')) + + def test_post(self): + __, return_dict = self.rest_client.post(self.url, {}, {}) + self.assertEqual('POST', return_dict['method']) + + def test_get(self): + __, return_dict = self.rest_client.get(self.url) + self.assertEqual('GET', return_dict['method']) + + def test_delete(self): + __, return_dict = self.rest_client.delete(self.url) + self.assertEqual('DELETE', return_dict['method']) + + def test_patch(self): + __, return_dict = self.rest_client.patch(self.url, {}, {}) + self.assertEqual('PATCH', return_dict['method']) + + def test_put(self): + __, return_dict = self.rest_client.put(self.url, {}, {}) + self.assertEqual('PUT', return_dict['method']) + + def test_head(self): + self.useFixture(mockpatch.PatchObject(self.rest_client, + 'response_checker')) + __, return_dict = self.rest_client.head(self.url) + self.assertEqual('HEAD', return_dict['method']) + + def test_copy(self): + __, return_dict = self.rest_client.copy(self.url) + self.assertEqual('COPY', return_dict['method']) + + +class TestRestClientNotFoundHandling(BaseRestClientTestClass): + def setUp(self): + self.fake_http = fake_http.fake_httplib2(404) + super(TestRestClientNotFoundHandling, self).setUp() + + def test_post(self): + self.assertRaises(exceptions.NotFound, self.rest_client.post, + self.url, {}, {}) + + +class TestRestClientHeadersJSON(TestRestClientHTTPMethods): + TYPE = "json" + + def _verify_headers(self, resp): + self.assertEqual(self.rest_client._get_type(), self.TYPE) + resp = dict((k.lower(), v) for k, v in six.iteritems(resp)) + self.assertEqual(self.header_value, resp['accept']) + self.assertEqual(self.header_value, resp['content-type']) + + def setUp(self): + super(TestRestClientHeadersJSON, self).setUp() + self.rest_client.TYPE = self.TYPE + self.header_value = 'application/%s' % self.rest_client._get_type() + + def test_post(self): + resp, __ = self.rest_client.post(self.url, {}) + self._verify_headers(resp) + + def test_get(self): + resp, __ = self.rest_client.get(self.url) + self._verify_headers(resp) + + def test_delete(self): + resp, __ = self.rest_client.delete(self.url) + self._verify_headers(resp) + + def test_patch(self): + resp, __ = self.rest_client.patch(self.url, {}) + self._verify_headers(resp) + + def test_put(self): + resp, __ = self.rest_client.put(self.url, {}) + self._verify_headers(resp) + + def test_head(self): + self.useFixture(mockpatch.PatchObject(self.rest_client, + 'response_checker')) + resp, __ = self.rest_client.head(self.url) + self._verify_headers(resp) + + def test_copy(self): + resp, __ = self.rest_client.copy(self.url) + self._verify_headers(resp) + + +class TestRestClientUpdateHeaders(BaseRestClientTestClass): + def setUp(self): + self.fake_http = fake_http.fake_httplib2() + super(TestRestClientUpdateHeaders, self).setUp() + self.useFixture(mockpatch.PatchObject(self.rest_client, + '_error_checker')) + self.headers = {'X-Configuration-Session': 'session_id'} + + def test_post_update_headers(self): + __, return_dict = self.rest_client.post(self.url, {}, + extra_headers=True, + headers=self.headers) + + self.assertDictContainsSubset( + {'X-Configuration-Session': 'session_id', + 'Content-Type': 'application/json', + 'Accept': 'application/json'}, + return_dict['headers'] + ) + + def test_get_update_headers(self): + __, return_dict = self.rest_client.get(self.url, + extra_headers=True, + headers=self.headers) + + self.assertDictContainsSubset( + {'X-Configuration-Session': 'session_id', + 'Content-Type': 'application/json', + 'Accept': 'application/json'}, + return_dict['headers'] + ) + + def test_delete_update_headers(self): + __, return_dict = self.rest_client.delete(self.url, + extra_headers=True, + headers=self.headers) + + self.assertDictContainsSubset( + {'X-Configuration-Session': 'session_id', + 'Content-Type': 'application/json', + 'Accept': 'application/json'}, + return_dict['headers'] + ) + + def test_patch_update_headers(self): + __, return_dict = self.rest_client.patch(self.url, {}, + extra_headers=True, + headers=self.headers) + + self.assertDictContainsSubset( + {'X-Configuration-Session': 'session_id', + 'Content-Type': 'application/json', + 'Accept': 'application/json'}, + return_dict['headers'] + ) + + def test_put_update_headers(self): + __, return_dict = self.rest_client.put(self.url, {}, + extra_headers=True, + headers=self.headers) + + self.assertDictContainsSubset( + {'X-Configuration-Session': 'session_id', + 'Content-Type': 'application/json', + 'Accept': 'application/json'}, + return_dict['headers'] + ) + + def test_head_update_headers(self): + self.useFixture(mockpatch.PatchObject(self.rest_client, + 'response_checker')) + + __, return_dict = self.rest_client.head(self.url, + extra_headers=True, + headers=self.headers) + + self.assertDictContainsSubset( + {'X-Configuration-Session': 'session_id', + 'Content-Type': 'application/json', + 'Accept': 'application/json'}, + return_dict['headers'] + ) + + def test_copy_update_headers(self): + __, return_dict = self.rest_client.copy(self.url, + extra_headers=True, + headers=self.headers) + + self.assertDictContainsSubset( + {'X-Configuration-Session': 'session_id', + 'Content-Type': 'application/json', + 'Accept': 'application/json'}, + return_dict['headers'] + ) + + +class TestRestClientParseRespJSON(BaseRestClientTestClass): + TYPE = "json" + + keys = ["fake_key1", "fake_key2"] + values = ["fake_value1", "fake_value2"] + item_expected = dict((key, value) for (key, value) in zip(keys, values)) + list_expected = {"body_list": [ + {keys[0]: values[0]}, + {keys[1]: values[1]}, + ]} + dict_expected = {"body_dict": { + keys[0]: values[0], + keys[1]: values[1], + }} + + def setUp(self): + self.fake_http = fake_http.fake_httplib2() + super(TestRestClientParseRespJSON, self).setUp() + self.rest_client.TYPE = self.TYPE + + def test_parse_resp_body_item(self): + body = self.rest_client._parse_resp(json.dumps(self.item_expected)) + self.assertEqual(self.item_expected, body) + + def test_parse_resp_body_list(self): + body = self.rest_client._parse_resp(json.dumps(self.list_expected)) + self.assertEqual(self.list_expected["body_list"], body) + + def test_parse_resp_body_dict(self): + body = self.rest_client._parse_resp(json.dumps(self.dict_expected)) + self.assertEqual(self.dict_expected["body_dict"], body) + + def test_parse_resp_two_top_keys(self): + dict_two_keys = self.dict_expected.copy() + dict_two_keys.update({"second_key": ""}) + body = self.rest_client._parse_resp(json.dumps(dict_two_keys)) + self.assertEqual(dict_two_keys, body) + + def test_parse_resp_one_top_key_without_list_or_dict(self): + data = {"one_top_key": "not_list_or_dict_value"} + body = self.rest_client._parse_resp(json.dumps(data)) + self.assertEqual(data, body) + + +class TestRestClientErrorCheckerJSON(base.TestCase): + c_type = "application/json" + + def set_data(self, r_code, enc=None, r_body=None): + if enc is None: + enc = self.c_type + resp_dict = {'status': r_code, 'content-type': enc} + resp = httplib2.Response(resp_dict) + data = { + "method": "fake_method", + "url": "fake_url", + "headers": "fake_headers", + "body": "fake_body", + "resp": resp, + "resp_body": '{"resp_body": "fake_resp_body"}', + } + if r_body is not None: + data.update({"resp_body": r_body}) + return data + + def setUp(self): + super(TestRestClientErrorCheckerJSON, self).setUp() + self.rest_client = rest_client.RestClient( + fake_auth_provider.FakeAuthProvider(), None, None) + + def test_response_less_than_400(self): + self.rest_client._error_checker(**self.set_data("399")) + + def test_response_400(self): + self.assertRaises(exceptions.BadRequest, + self.rest_client._error_checker, + **self.set_data("400")) + + def test_response_401(self): + self.assertRaises(exceptions.Unauthorized, + self.rest_client._error_checker, + **self.set_data("401")) + + def test_response_403(self): + self.assertRaises(exceptions.Unauthorized, + self.rest_client._error_checker, + **self.set_data("403")) + + def test_response_404(self): + self.assertRaises(exceptions.NotFound, + self.rest_client._error_checker, + **self.set_data("404")) + + def test_response_409(self): + self.assertRaises(exceptions.Conflict, + self.rest_client._error_checker, + **self.set_data("409")) + + def test_response_413(self): + self.assertRaises(exceptions.OverLimit, + self.rest_client._error_checker, + **self.set_data("413")) + + def test_response_415(self): + self.assertRaises(exceptions.InvalidContentType, + self.rest_client._error_checker, + **self.set_data("415")) + + def test_response_422(self): + self.assertRaises(exceptions.UnprocessableEntity, + self.rest_client._error_checker, + **self.set_data("422")) + + def test_response_500_with_text(self): + # _parse_resp is expected to return 'str' + self.assertRaises(exceptions.ServerFault, + self.rest_client._error_checker, + **self.set_data("500")) + + def test_response_501_with_text(self): + self.assertRaises(exceptions.NotImplemented, + self.rest_client._error_checker, + **self.set_data("501")) + + def test_response_500_with_dict(self): + r_body = '{"resp_body": {"err": "fake_resp_body"}}' + self.assertRaises(exceptions.ServerFault, + self.rest_client._error_checker, + **self.set_data("500", r_body=r_body)) + + def test_response_501_with_dict(self): + r_body = '{"resp_body": {"err": "fake_resp_body"}}' + self.assertRaises(exceptions.NotImplemented, + self.rest_client._error_checker, + **self.set_data("501", r_body=r_body)) + + def test_response_bigger_than_400(self): + # Any response code, that bigger than 400, and not in + # (401, 403, 404, 409, 413, 422, 500, 501) + self.assertRaises(exceptions.UnexpectedResponseCode, + self.rest_client._error_checker, + **self.set_data("402")) + + +class TestRestClientErrorCheckerTEXT(TestRestClientErrorCheckerJSON): + c_type = "text/plain" + + def test_fake_content_type(self): + # This test is required only in one exemplar + # Any response code, that bigger than 400, and not in + # (401, 403, 404, 409, 413, 422, 500, 501) + self.assertRaises(exceptions.InvalidContentType, + self.rest_client._error_checker, + **self.set_data("405", enc="fake_enc")) + + +class TestRestClientUtils(BaseRestClientTestClass): + + def _is_resource_deleted(self, resource_id): + if not isinstance(self.retry_pass, int): + return False + if self.retry_count >= self.retry_pass: + return True + self.retry_count = self.retry_count + 1 + return False + + def setUp(self): + self.fake_http = fake_http.fake_httplib2() + super(TestRestClientUtils, self).setUp() + self.retry_count = 0 + self.retry_pass = None + self.original_deleted_method = self.rest_client.is_resource_deleted + self.rest_client.is_resource_deleted = self._is_resource_deleted + + def test_wait_for_resource_deletion(self): + self.retry_pass = 2 + # Ensure timeout long enough for loop execution to hit retry count + self.rest_client.build_timeout = 500 + sleep_mock = self.patch('time.sleep') + self.rest_client.wait_for_resource_deletion('1234') + self.assertEqual(len(sleep_mock.mock_calls), 2) + + def test_wait_for_resource_deletion_not_deleted(self): + self.patch('time.sleep') + # Set timeout to be very quick to force exception faster + self.rest_client.build_timeout = 1 + self.assertRaises(exceptions.TimeoutException, + self.rest_client.wait_for_resource_deletion, + '1234') + + def test_wait_for_deletion_with_unimplemented_deleted_method(self): + self.rest_client.is_resource_deleted = self.original_deleted_method + self.assertRaises(NotImplementedError, + self.rest_client.wait_for_resource_deletion, + '1234') + + +class TestExpectedSuccess(BaseRestClientTestClass): + + def setUp(self): + self.fake_http = fake_http.fake_httplib2() + super(TestExpectedSuccess, self).setUp() + + def test_expected_succes_int_match(self): + expected_code = 202 + read_code = 202 + resp = self.rest_client.expected_success(expected_code, read_code) + # Assert None resp on success + self.assertFalse(resp) + + def test_expected_succes_int_no_match(self): + expected_code = 204 + read_code = 202 + self.assertRaises(exceptions.InvalidHttpSuccessCode, + self.rest_client.expected_success, + expected_code, read_code) + + def test_expected_succes_list_match(self): + expected_code = [202, 204] + read_code = 202 + resp = self.rest_client.expected_success(expected_code, read_code) + # Assert None resp on success + self.assertFalse(resp) + + def test_expected_succes_list_no_match(self): + expected_code = [202, 204] + read_code = 200 + self.assertRaises(exceptions.InvalidHttpSuccessCode, + self.rest_client.expected_success, + expected_code, read_code) + + def test_non_success_expected_int(self): + expected_code = 404 + read_code = 202 + self.assertRaises(AssertionError, self.rest_client.expected_success, + expected_code, read_code) + + def test_non_success_expected_list(self): + expected_code = [404, 202] + read_code = 202 + self.assertRaises(AssertionError, self.rest_client.expected_success, + expected_code, read_code) diff --git a/test-requirements.txt b/test-requirements.txt index 7423b40..aed1f05 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -8,6 +8,7 @@ discover python-subunit>=0.0.18 sphinx>=1.1.2,!=1.2.0,!=1.3b1,<1.3 oslosphinx>=2.2.0 # Apache-2.0 +oslotest>=1.2.0 # Apache-2.0 testrepository>=0.0.18 testscenarios>=0.4 testtools>=0.9.36,!=1.2.0 |