diff --git a/.testr.conf b/.testr.conf index 6060451ed5..e35c8d8488 100644 --- a/.testr.conf +++ b/.testr.conf @@ -6,3 +6,13 @@ test_command=OS_STDOUT_CAPTURE=${OS_STDOUT_CAPTURE:-1} \ test_id_option=--load-list $IDFILE test_list_option=--list +# NOTE(cdent): The group_regex describes how testrepository will +# group tests into the same process when running concurently. The +# following insures that gabbi tests coming from the same YAML file +# are all in the same process. This is important because each YAML +# file represents an ordered sequence of HTTP requests. Note that +# tests which do not match this regex will not be grouped in any +# special way. See the following for more details. +# http://testrepository.readthedocs.io/en/latest/MANUAL.html#grouping-tests +# https://gabbi.readthedocs.io/en/latest/#purpose +group_regex=(gabbi\.(?:driver|suitemaker)\.test_placement_api_([^_]+)) diff --git a/nova/api/openstack/placement/__init__.py b/nova/api/openstack/placement/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/nova/api/openstack/placement/auth.py b/nova/api/openstack/placement/auth.py new file mode 100644 index 0000000000..8bdfb8ea4a --- /dev/null +++ b/nova/api/openstack/placement/auth.py @@ -0,0 +1,73 @@ +# 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. + + +from oslo_context import context +from oslo_log import log as logging +from oslo_middleware import request_id +import webob.dec +import webob.exc + +from nova import conf + + +CONF = conf.CONF +LOG = logging.getLogger(__name__) + + +class Middleware(object): + + def __init__(self, application, **kwargs): + self.application = application + + +# NOTE(cdent): Only to be used in tests where auth is being faked. +class NoAuthMiddleware(Middleware): + """Require a token if one isn't present.""" + + def __init__(self, application): + self.application = application + + @webob.dec.wsgify + def __call__(self, req): + if 'X-Auth-Token' not in req.headers: + return webob.exc.HTTPUnauthorized() + + token = req.headers['X-Auth-Token'] + user_id, _sep, project_id = token.partition(':') + project_id = project_id or user_id + if user_id == 'admin': + roles = ['admin'] + else: + roles = [] + req.headers['X_USER_ID'] = user_id + req.headers['X_TENANT_ID'] = project_id + req.headers['X_ROLES'] = ','.join(roles) + return self.application + + +class PlacementKeystoneContext(Middleware): + """Make a request context from keystone headers.""" + + @webob.dec.wsgify + def __call__(self, req): + req_id = req.environ.get(request_id.ENV_REQUEST_ID) + + ctx = context.RequestContext.from_environ( + req.environ, request_id=req_id) + + if ctx.user is None: + LOG.debug("Neither X_USER_ID nor X_USER found in request") + return webob.exc.HTTPUnauthorized() + + req.environ['placement.context'] = ctx + return self.application diff --git a/nova/api/openstack/placement/deploy.py b/nova/api/openstack/placement/deploy.py new file mode 100644 index 0000000000..416444e895 --- /dev/null +++ b/nova/api/openstack/placement/deploy.py @@ -0,0 +1,55 @@ +# 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. +"""Deployment handling for Placmenent API.""" + +from keystonemiddleware import auth_token +from oslo_middleware import request_id + +from nova.api.openstack.placement import auth +from nova.api.openstack.placement import handler +from nova.api.openstack.placement import microversion + +# TODO(cdent): register objects here as this is our startup place, +# but only once we start using them. + +# TODO(cdent): NAME points to the config project being used, so for +# now this is "nova" but we probably want "placement" eventually. +NAME = "nova" + + +def deploy(conf, project_name): + """Assemble the middleware pipeline leading to the placement app.""" + if conf.auth_strategy == 'noauth2': + auth_middleware = auth.NoAuthMiddleware + else: + # Do not provide global conf to middleware here. + auth_middleware = auth_token.filter_factory( + {}, olso_config_project=project_name) + + context_middleware = auth.PlacementKeystoneContext + req_id_middleware = request_id.RequestId + microversion_middleware = microversion.MicroversionMiddleware + + application = handler.PlacementHandler() + + for middleware in (context_middleware, + auth_middleware, + microversion_middleware, + req_id_middleware): + application = middleware(application) + + return application + + +def loadapp(config, project_name=NAME): + application = deploy(config, project_name) + return application diff --git a/nova/api/openstack/placement/handler.py b/nova/api/openstack/placement/handler.py new file mode 100644 index 0000000000..2ebc76951a --- /dev/null +++ b/nova/api/openstack/placement/handler.py @@ -0,0 +1,114 @@ +# 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. +"""Handlers for placement API. + +Individual handlers are associated with URL paths in the +ROUTE_DECLARATIONS dictionary. At the top level each key is a Routes +compliant path. The value of that key is a dictionary mapping +individual HTTP request methods to a Python function representing a +simple WSGI application for satisfying that request. + +The ``make_map`` method processes ROUTE_DECLARATIONS to create a +Routes.Mapper, including automatic handlers to respond with a +405 when a request is made against a valid URL with an invalid +method. +""" + +import routes +import webob + +from nova.api.openstack.placement.handlers import root +from nova.api.openstack.placement import util + + +# URLs and Handlers +ROUTE_DECLARATIONS = { + '/': { + 'GET': root.home, + }, +} + + +def dispatch(environ, start_response, mapper): + """Find a matching route for the current request. + + If no match is found, raise a 404 response. + If there is a matching route, but no matching handler + for the given method, raise a 405. + """ + result = mapper.match(environ=environ) + if result is None: + raise webob.exc.HTTPNotFound( + json_formatter=util.json_error_formatter) + # We can't reach this code without action being present. + handler = result.pop('action') + environ['wsgiorg.routing_args'] = ((), result) + return handler(environ, start_response) + + +def handle_405(environ, start_response): + """Return a 405 response as required. + + If _methods are in routing_args, send an allow header listing + the methods that are possible on the provided URL. + """ + _methods = util.wsgi_path_item(environ, '_methods') + headers = {} + if _methods: + headers['allow'] = _methods + raise webob.exc.HTTPMethodNotAllowed( + 'The method specified is not allowed for this resource.', + headers=headers, json_formatter=util.json_error_formatter) + + +def make_map(declarations): + """Process route declarations to create a Route Mapper.""" + mapper = routes.Mapper() + for route, targets in declarations.items(): + allowed_methods = [] + for method in targets: + mapper.connect(route, action=targets[method], + conditions=dict(method=[method])) + allowed_methods.append(method) + allowed_methods = ', '.join(allowed_methods) + mapper.connect(route, action=handle_405, _methods=allowed_methods) + return mapper + + +class PlacementHandler(object): + """Serve Placement API. + + Dispatch to handlers defined in ROUTE_DECLARATIONS. + """ + + def __init__(self, **local_config): + # NOTE(cdent): Local config currently unused. + self._map = make_map(ROUTE_DECLARATIONS) + + def __call__(self, environ, start_response): + # All requests but '/' require admin. + # TODO(cdent): We'll eventually want our own auth context, + # but using nova's is convenient for now. + if environ['PATH_INFO'] != '/': + context = environ['placement.context'] + # TODO(cdent): Using is_admin everywhere (except /) is + # insufficiently flexible for future use case but is + # convenient for initial exploration. We will need to + # determine how to manage authorization/policy and + # implement that, probably per handler. Also this is + # just the wrong way to do things, but policy not + # integrated yet. + if 'admin' not in context.to_policy_values()['roles']: + raise webob.exc.HTTPForbidden( + 'admin required', + json_formatter=util.json_error_formatter) + return dispatch(environ, start_response, self._map) diff --git a/nova/api/openstack/placement/handlers/__init__.py b/nova/api/openstack/placement/handlers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/nova/api/openstack/placement/handlers/root.py b/nova/api/openstack/placement/handlers/root.py new file mode 100644 index 0000000000..24238d176d --- /dev/null +++ b/nova/api/openstack/placement/handlers/root.py @@ -0,0 +1,36 @@ +# 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. +"""Handler for the root of the Placement API.""" + +from oslo_serialization import jsonutils +import webob + + +from nova.api.openstack.placement import microversion + + +@webob.dec.wsgify +def home(req): + min_version = microversion.min_version_string() + max_version = microversion.max_version_string() + # NOTE(cdent): As sections of the api are added, links can be + # added to this output to align with the guidelines at + # http://specs.openstack.org/openstack/api-wg/guidelines/microversion_specification.html#version-discovery + version_data = { + 'id': 'v%s' % min_version, + 'max_version': max_version, + 'min_version': min_version, + } + version_json = jsonutils.dumps({'versions': [version_data]}) + req.response.body = version_json + req.response.content_type = 'application/json' + return req.response diff --git a/nova/api/openstack/placement/microversion.py b/nova/api/openstack/placement/microversion.py new file mode 100644 index 0000000000..1282d0f229 --- /dev/null +++ b/nova/api/openstack/placement/microversion.py @@ -0,0 +1,143 @@ +# 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. +"""Microversion handling.""" + +# NOTE(cdent): This code is taken from enamel: +# https://github.com/jaypipes/enamel and was the original source of +# the code now used in microversion_parse library. + +import collections + +import microversion_parse +import webob + +from nova.api.openstack.placement import util + + +SERVICE_TYPE = 'placement' +MICROVERSION_ENVIRON = '%s.microversion' % SERVICE_TYPE + +# The Canonical Version List +VERSIONS = [ + '1.0', +] + + +def max_version_string(): + return VERSIONS[-1] + + +def min_version_string(): + return VERSIONS[0] + + +def parse_version_string(version_string): + """Turn a version string into a Version + + :param version_string: A string of two numerals, X.Y, or 'latest' + :returns: a Version + :raises: TypeError + """ + if version_string == 'latest': + version_string = max_version_string() + try: + # The combination of int and a limited split with the + # named tuple means that this incantation will raise + # ValueError or TypeError when the incoming data is + # poorly formed but will, however, naturally adapt to + # extraneous whitespace. + return Version(*(int(value) for value + in version_string.split('.', 1))) + except (ValueError, TypeError) as exc: + raise TypeError('invalid version string: %s; %s' % ( + version_string, exc)) + + +class MicroversionMiddleware(object): + """WSGI middleware for getting microversion info.""" + + def __init__(self, application): + self.application = application + + @webob.dec.wsgify + def __call__(self, req): + try: + req.environ[MICROVERSION_ENVIRON] = extract_version( + req.headers) + except ValueError as exc: + raise webob.exc.HTTPNotAcceptable( + 'Invalid microversion: %s' % exc, + json_formatter=util.json_error_formatter) + except TypeError as exc: + raise webob.exc.HTTPBadRequest( + 'Invalid microversion: %s' % exc, + json_formatter=util.json_error_formatter) + response = req.get_response(self.application) + response.headers.add(Version.HEADER, + '%s %s' % (SERVICE_TYPE, + req.environ[MICROVERSION_ENVIRON])) + response.headers.add('vary', Version.HEADER) + return response + + +class Version(collections.namedtuple('Version', 'major minor')): + """A namedtuple containing major and minor values. + + Since it is a tuple is automatically comparable. + """ + + HEADER = 'OpenStack-API-Version' + + MIN_VERSION = None + MAX_VERSION = None + + def __str__(self): + return '%s.%s' % (self.major, self.minor) + + @property + def max_version(self): + if not self.MAX_VERSION: + self.MAX_VERSION = parse_version_string(max_version_string()) + return self.MAX_VERSION + + @property + def min_version(self): + if not self.MIN_VERSION: + self.MIN_VERSION = parse_version_string(min_version_string()) + return self.MIN_VERSION + + def matches(self, min_version=None, max_version=None): + if min_version is None: + min_version = self.min_version + if max_version is None: + max_version = self.max_version + return min_version <= self <= max_version + + +def extract_version(headers): + """Extract the microversion from Version.HEADER + + There may be multiple headers and some which don't match our + service. + """ + found_version = microversion_parse.get_version(headers, + service_type=SERVICE_TYPE) + + version_string = found_version or min_version_string() + request_version = parse_version_string(version_string) + # We need a version that is in VERSION and within MIX and MAX. + # This gives us the option to administratively disable a + # version if we really need to. + if (str(request_version) in VERSIONS and request_version.matches()): + return request_version + raise ValueError('Unacceptable version header: %s' % version_string) diff --git a/nova/api/openstack/placement/placement-api.py b/nova/api/openstack/placement/placement-api.py new file mode 100644 index 0000000000..dcd6b1637c --- /dev/null +++ b/nova/api/openstack/placement/placement-api.py @@ -0,0 +1,27 @@ +# 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. +"""WSGI script for Placement API + +WSGI handler for running Placement API under Apache2, nginx, gunicorn etc. +""" + +from nova.api.openstack.placement import deploy +from nova import conf +from nova import config + + +CONFIG_FILE = '/etc/nova/nova.conf' + + +config.parse_args([], default_config_files=[CONFIG_FILE]) + +application = deploy.loadapp(conf.CONF) diff --git a/nova/api/openstack/placement/util.py b/nova/api/openstack/placement/util.py new file mode 100644 index 0000000000..054a70f0af --- /dev/null +++ b/nova/api/openstack/placement/util.py @@ -0,0 +1,65 @@ +# 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. +"""Utility methods for placement API.""" + +from oslo_middleware import request_id +import webob + +# NOTE(cdent): avoid cyclical import conflict between util and +# microversion +import nova.api.openstack.placement.microversion + + +def json_error_formatter(body, status, title, environ): + """A json_formatter for webob exceptions. + + Follows API-WG guidelines at + http://specs.openstack.org/openstack/api-wg/guidelines/errors.html + """ + # Clear out the html that webob sneaks in. + body = webob.exc.strip_tags(body) + error_dict = { + 'status': status, + 'title': title, + 'detail': body + } + # If the request id middleware has had a chance to add an id, + # put it in the error response. + if request_id.ENV_REQUEST_ID in environ: + error_dict['request_id'] = environ[request_id.ENV_REQUEST_ID] + + # When there is a no microversion in the environment and a 406, + # microversion parsing failed so we need to include microversion + # min and max information in the error response. + microversion = nova.api.openstack.placement.microversion + # Get status code out of status message. webob's error formatter + # only passes entire status string. + code = status.split(None, 1)[0] + if code == '406' and microversion.MICROVERSION_ENVIRON not in environ: + error_dict['max_version'] = microversion.max_version_string() + error_dict['min_version'] = microversion.max_version_string() + + return {'errors': [error_dict]} + + +def wsgi_path_item(environ, name): + """Extract the value of a named field in a URL. + + Return None if the name is not present or there are no path items. + """ + # NOTE(cdent): For the time being we don't need to urldecode + # the value as the entire placement API has paths that accept no + # encoded values. + try: + return environ['wsgiorg.routing_args'][1][name] + except (KeyError, IndexError): + return None diff --git a/nova/tests/functional/api/openstack/__init__.py b/nova/tests/functional/api/openstack/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/nova/tests/functional/api/openstack/placement/__init__.py b/nova/tests/functional/api/openstack/placement/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/nova/tests/functional/api/openstack/placement/fixtures.py b/nova/tests/functional/api/openstack/placement/fixtures.py new file mode 100644 index 0000000000..28f10994ff --- /dev/null +++ b/nova/tests/functional/api/openstack/placement/fixtures.py @@ -0,0 +1,41 @@ +# 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. + +from gabbi import fixture + +from nova.api.openstack.placement import deploy +from nova import conf +from nova import config + + +CONF = conf.CONF + + +def setup_app(): + return deploy.loadapp(CONF) + + +class APIFixture(fixture.GabbiFixture): + """Setup the required backend fixtures for a basic placement service.""" + + def __init__(self): + self.conf = None + + def start_fixture(self): + self.conf = CONF + config.parse_args([], default_config_files=None, configure_db=False, + init_rpc=False) + self.conf.set_override('auth_strategy', 'noauth2') + + def stop_fixture(self): + if self.conf: + self.conf.reset() diff --git a/nova/tests/functional/api/openstack/placement/gabbits/basic-http.yaml b/nova/tests/functional/api/openstack/placement/gabbits/basic-http.yaml new file mode 100644 index 0000000000..e049dce056 --- /dev/null +++ b/nova/tests/functional/api/openstack/placement/gabbits/basic-http.yaml @@ -0,0 +1,39 @@ +# +# Test the basic handling of HTTP (expected response codes and the +# like). +# + +fixtures: + - APIFixture + +defaults: + request_headers: + # NOTE(cdent): Get past keystone, even though at this stage + # we don't require auth. + x-auth-token: admin + accept: application/json + +tests: +- name: 404 at no service + GET: /barnabas + status: 404 + response_json_paths: + $.errors[0].title: Not Found + +- name: error message has request id + GET: /barnabas + status: 404 + response_json_paths: + $.errors[0].request_id: /req-[a-fA-F0-9-]+/ + +- name: 405 on bad method at root + DELETE: / + status: 405 + response_headers: + allow: GET + response_json_paths: + $.errors[0].title: Method Not Allowed + +- name: 200 at home + GET: / + status: 200 diff --git a/nova/tests/functional/api/openstack/placement/gabbits/confirm-auth.yaml b/nova/tests/functional/api/openstack/placement/gabbits/confirm-auth.yaml new file mode 100644 index 0000000000..68742e5d9b --- /dev/null +++ b/nova/tests/functional/api/openstack/placement/gabbits/confirm-auth.yaml @@ -0,0 +1,22 @@ +# +# Confirm that the noauth handler is causing a 401 when no fake +# token is provided. +# + +fixtures: + - APIFixture + +defaults: + request_headers: + accept: application/json + +tests: + - name: no token gets 401 + GET: / + status: 401 + + - name: with token 200 + GET: / + request_headers: + x-auth-token: admin:admin + status: 200 diff --git a/nova/tests/functional/api/openstack/placement/gabbits/microversion.yaml b/nova/tests/functional/api/openstack/placement/gabbits/microversion.yaml new file mode 100644 index 0000000000..9eeabc869a --- /dev/null +++ b/nova/tests/functional/api/openstack/placement/gabbits/microversion.yaml @@ -0,0 +1,73 @@ +# Tests to build microversion functionality behavior and confirm +# it is present and behaving as expected. + +fixtures: + - APIFixture + +defaults: + request_headers: + accept: application/json + x-auth-token: user + +tests: +- name: root has microversion header + GET: / + response_headers: + vary: /OpenStack-API-Version/ + openstack-api-version: /^placement \d+\.\d+$/ + +- name: root has microversion info + GET: / + response_json_paths: + $.versions[0].max_version: /^\d+\.\d+$/ + $.versions[0].min_version: /^\d+\.\d+$/ + $.versions[0].id: v1.0 + +- name: unavailable microversion raises 406 + GET: / + request_headers: + openstack-api-version: placement 0.5 + status: 406 + response_headers: + content-type: /application/json/ + response_json_paths: + $.errors.[0].title: Not Acceptable + $.errors.[0].max_version: /^\d+\.\d+$/ + $.errors.[0].min_version: /^\d+\.\d+$/ + response_strings: + - "Unacceptable version header: 0.5" + +- name: latest microversion is 1.0 + GET: / + request_headers: + openstack-api-version: placement latest + response_headers: + vary: /OpenStack-API-Version/ + openstack-api-version: placement 1.0 + +- name: other accept header bad version + GET: / + request_headers: + accept: text/html + openstack-api-version: placement 0.5 + status: 406 + response_headers: + content-type: /text/html/ + response_strings: + - "Unacceptable version header: 0.5" + +- name: bad format string raises 400 + GET: / + request_headers: + openstack-api-version: placement pony.horse + status: 400 + response_strings: + - "invalid version string: pony.horse" + +- name: bad format multidot raises 400 + GET: / + request_headers: + openstack-api-version: placement 1.2.3 + status: 400 + response_strings: + - "invalid version string: 1.2.3" diff --git a/nova/tests/functional/api/openstack/placement/test_placement_api.py b/nova/tests/functional/api/openstack/placement/test_placement_api.py new file mode 100644 index 0000000000..2e28d9556c --- /dev/null +++ b/nova/tests/functional/api/openstack/placement/test_placement_api.py @@ -0,0 +1,27 @@ +# 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 os + +from gabbi import driver + +from nova.tests.functional.api.openstack.placement import fixtures + +TESTS_DIR = 'gabbits' + + +def load_tests(loader, tests, pattern): + """Provide a TestSuite to the discovery process.""" + test_dir = os.path.join(os.path.dirname(__file__), TESTS_DIR) + return driver.build_tests(test_dir, loader, host=None, + intercept=fixtures.setup_app, + fixture_module=fixtures) diff --git a/nova/tests/unit/api/openstack/placement/__init__.py b/nova/tests/unit/api/openstack/placement/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/nova/tests/unit/api/openstack/placement/test_handler.py b/nova/tests/unit/api/openstack/placement/test_handler.py new file mode 100644 index 0000000000..38da37b654 --- /dev/null +++ b/nova/tests/unit/api/openstack/placement/test_handler.py @@ -0,0 +1,99 @@ +# 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. +"""Unit tests for the functions used by the placement API handlers.""" + +import mock +import routes +import webob + +from nova.api.openstack.placement import handler +from nova import test +from nova.tests import uuidsentinel + + +# Used in tests below +def start_response(*args, **kwargs): + pass + + +def _environ(path='/moo', method='GET'): + return { + 'PATH_INFO': path, + 'REQUEST_METHOD': method, + 'SERVER_NAME': 'example.com', + 'SERVER_PORT': '80', + 'wsgi.url_scheme': 'http', + } + + +class DispatchTest(test.NoDBTestCase): + + def setUp(self): + super(DispatchTest, self).setUp() + self.mapper = routes.Mapper() + self.route_handler = mock.MagicMock() + + def test_no_match_null_map(self): + self.assertRaises(webob.exc.HTTPNotFound, + handler.dispatch, + _environ(), start_response, + self.mapper) + + def test_no_match_with_map(self): + self.mapper.connect('/foobar', action='hello') + self.assertRaises(webob.exc.HTTPNotFound, + handler.dispatch, + _environ(), start_response, + self.mapper) + + def test_simple_match(self): + self.mapper.connect('/foobar', action=self.route_handler, + conditions=dict(method=['GET'])) + environ = _environ(path='/foobar') + handler.dispatch(environ, start_response, self.mapper) + self.route_handler.assert_called_with(environ, start_response) + + def test_simple_match_routing_args(self): + self.mapper.connect('/foobar/{id}', action=self.route_handler, + conditions=dict(method=['GET'])) + environ = _environ(path='/foobar/%s' % uuidsentinel.foobar) + handler.dispatch(environ, start_response, self.mapper) + self.route_handler.assert_called_with(environ, start_response) + self.assertEqual(uuidsentinel.foobar, + environ['wsgiorg.routing_args'][1]['id']) + + +class MapperTest(test.NoDBTestCase): + + def setUp(self): + super(MapperTest, self).setUp() + declarations = { + '/hello': {'GET': 'hello'} + } + self.mapper = handler.make_map(declarations) + + def test_no_match(self): + environ = _environ(path='/cow') + self.assertIsNone(self.mapper.match(environ=environ)) + + def test_match(self): + environ = _environ(path='/hello') + action = self.mapper.match(environ=environ)['action'] + self.assertEqual('hello', action) + + def test_405(self): + environ = _environ(path='/hello', method='POST') + result = self.mapper.match(environ=environ) + self.assertEqual(handler.handle_405, result['action']) + self.assertEqual('GET', result['_methods']) diff --git a/test-requirements.txt b/test-requirements.txt index 5d97065f4b..98bc16d34b 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -24,6 +24,7 @@ testtools>=1.4.0 # MIT tempest-lib>=0.14.0 # Apache-2.0 bandit>=1.0.1 # Apache-2.0 openstackdocstheme>=1.4.0 # Apache-2.0 +gabbi>=1.24.0 # Apache-2.0 # vmwareapi driver specific dependencies oslo.vmware>=2.11.0 # Apache-2.0