diff options
Diffstat (limited to 'glance/tests/functional/__init__.py')
-rw-r--r-- | glance/tests/functional/__init__.py | 213 |
1 files changed, 213 insertions, 0 deletions
diff --git a/glance/tests/functional/__init__.py b/glance/tests/functional/__init__.py index 8fda3b822..fe2e6fdcb 100644 --- a/glance/tests/functional/__init__.py +++ b/glance/tests/functional/__init__.py @@ -34,9 +34,13 @@ import socket import subprocess import sys import tempfile +import textwrap import time +from unittest import mock +import uuid import fixtures +import glance_store from os_win import utilsfactory as os_win_utilsfactory from oslo_config import cfg from oslo_serialization import jsonutils @@ -44,8 +48,11 @@ from oslo_serialization import jsonutils from six.moves import range import six.moves.urllib.parse as urlparse import testtools +import webob +from glance.common import config from glance.common import utils +from glance.common import wsgi from glance.db.sqlalchemy import api as db_api from glance import tests as glance_tests from glance.tests import utils as test_utils @@ -1477,3 +1484,209 @@ class MultipleBackendFunctionalTest(test_utils.BaseTestCase): self._attached_server_logs.append(s.log_file) self.addDetail( s.server_name, testtools.content.text_content(s.dump_log())) + + +class SynchronousAPIBase(test_utils.BaseTestCase): + """A base class that provides synchronous calling into the API. + + This provides a way to directly call into the API WSGI stack + without starting a separate server, and with a simple paste + pipeline. Configured with multi-store and a real database. + + This differs from the FunctionalTest lineage above in that they + start a full copy of the API server as a separate process, whereas + this calls directly into the WSGI stack. This test base is + appropriate for situations where you need to be able to mock the + state of the world (i.e. warp time, or inject errors) but should + not be used for happy-path testing where FunctionalTest provides + more isolation. + + To use this, inherit and run start_server() before you are ready + to make API calls (either in your setUp() or per-test if you need + to change config or mocking). + + Once started, use the api_get(), api_put(), api_post(), and + api_delete() methods to make calls to the API. + + """ + + TENANT = str(uuid.uuid4()) + + @mock.patch('oslo_db.sqlalchemy.enginefacade.writer.get_engine') + def setup_database(self, mock_get_engine): + """Configure and prepare a fresh sqlite database.""" + db_file = 'sqlite:///%s/test.db' % self.test_dir + self.config(connection=db_file, group='database') + + # NOTE(danms): Make sure that we clear the current global + # database configuration, provision a temporary database file, + # and run migrations with our configuration to define the + # schema there. + db_api.clear_db_env() + engine = db_api.get_engine() + mock_get_engine.return_value = engine + with mock.patch('logging.config'): + # NOTE(danms): The alembic config in the env module will break our + # BaseTestCase logging setup. So mock that out to prevent it while + # we db_sync. + test_utils.db_sync(engine=engine) + + def setup_simple_paste(self): + """Setup a very simple no-auth paste pipeline. + + This configures the API to be very direct, including only the + middleware absolutely required for consistent API calls. + """ + self.paste_config = os.path.join(self.test_dir, 'glance-api-paste.ini') + with open(self.paste_config, 'w') as f: + f.write(textwrap.dedent(""" + [filter:context] + paste.filter_factory = glance.api.middleware.context:\ + ContextMiddleware.factory + [filter:fakeauth] + paste.filter_factory = glance.tests.utils:\ + FakeAuthMiddleware.factory + [pipeline:glance-api] + pipeline = context rootapp + [composite:rootapp] + paste.composite_factory = glance.api:root_app_factory + /v2: apiv2app + [app:apiv2app] + paste.app_factory = glance.api.v2.router:API.factory + """)) + + def _store_dir(self, store): + return os.path.join(self.test_dir, store) + + def setup_stores(self): + """Configures multiple backend stores. + + This configures the API with two file-backed stores (store1 + and store2) as well as a os_glance_staging_store for imports. + + """ + self.config(enabled_backends={'store1': 'file', 'store2': 'file'}) + glance_store.register_store_opts(CONF, + reserved_stores=wsgi.RESERVED_STORES) + self.config(default_backend='store1', + group='glance_store') + self.config(filesystem_store_datadir=self._store_dir('store1'), + group='store1') + self.config(filesystem_store_datadir=self._store_dir('store2'), + group='store2') + self.config(filesystem_store_datadir=self._store_dir('staging'), + group='os_glance_staging_store') + + glance_store.create_multi_stores(CONF, + reserved_stores=wsgi.RESERVED_STORES) + glance_store.verify_store() + + def setUp(self): + super(SynchronousAPIBase, self).setUp() + + self.setup_database() + self.setup_simple_paste() + self.setup_stores() + + def start_server(self): + """Builds and "starts" the API server. + + Note that this doesn't actually "start" anything like + FunctionalTest does above, but that terminology is used here + to make it seem like the same sort of pattern. + """ + config.set_config_defaults() + self.api = config.load_paste_app('glance-api', + conf_file=self.paste_config) + + def _headers(self, custom_headers=None): + base_headers = { + 'X-Identity-Status': 'Confirmed', + 'X-Auth-Token': '932c5c84-02ac-4fe5-a9ba-620af0e2bb96', + 'X-User-Id': 'f9a41d13-0c13-47e9-bee2-ce4e8bfe958e', + 'X-Tenant-Id': self.TENANT, + 'Content-Type': 'application/json', + 'X-Roles': 'admin', + } + base_headers.update(custom_headers or {}) + return base_headers + + def api_request(self, method, url, headers=None, data=None, + json=None, body_file=None): + """Perform a request against the API. + + NOTE: Most code should use api_get(), api_post(), api_put(), + or api_delete() instead! + + :param method: The HTTP method to use (i.e. GET, POST, etc) + :param url: The *path* part of the URL to call (i.e. /v2/images) + :param headers: Optional updates to the default set of headers + :param data: Optional bytes data payload to send (overrides @json) + :param json: Optional dict structure to be jsonified and sent as + the payload (mutually exclusive with @data) + :param body_file: Optional io.IOBase to provide as the input data + stream for the request (overrides @data) + :returns: A webob.Response object + """ + headers = self._headers(headers) + req = webob.Request.blank(url, method=method, + headers=headers) + if json and not data: + data = jsonutils.dumps(json).encode() + if data and not body_file: + req.body = data + elif body_file: + req.body_file = body_file + return self.api(req) + + def api_get(self, url, headers=None): + """Perform a GET request against the API. + + :param url: The *path* part of the URL to call (i.e. /v2/images) + :param headers: Optional updates to the default set of headers + :returns: A webob.Response object + """ + return self.api_request('GET', url, headers=headers) + + def api_post(self, url, headers=None, data=None, json=None, + body_file=None): + """Perform a POST request against the API. + + :param url: The *path* part of the URL to call (i.e. /v2/images) + :param headers: Optional updates to the default set of headers + :param data: Optional bytes data payload to send (overrides @json) + :param json: Optional dict structure to be jsonified and sent as + the payload (mutually exclusive with @data) + :param body_file: Optional io.IOBase to provide as the input data + stream for the request (overrides @data) + :returns: A webob.Response object + """ + return self.api_request('POST', url, headers=headers, + data=data, json=json, + body_file=body_file) + + def api_put(self, url, headers=None, data=None, json=None, body_file=None): + """Perform a PUT request against the API. + + :param url: The *path* part of the URL to call (i.e. /v2/images) + :param headers: Optional updates to the default set of headers + :param data: Optional bytes data payload to send (overrides @json, + mutually exclusive with body_file) + :param json: Optional dict structure to be jsonified and sent as + the payload (mutually exclusive with @data) + :param body_file: Optional io.IOBase to provide as the input data + stream for the request (overrides @data) + :returns: A webob.Response object + """ + return self.api_request('PUT', url, headers=headers, + data=data, json=json, + body_file=body_file) + + def api_delete(self, url, headers=None): + """Perform a DELETE request against the API. + + :param url: The *path* part of the URL to call (i.e. /v2/images) + :param headers: Optional updates to the default set of headers + :returns: A webob.Response object + """ + return self.api_request('DELETE', url, heaers=headers) |